flexgate 0.1.0__py3-none-any.whl
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.
- flexgate/__init__.py +3 -0
- flexgate/__main__.py +3 -0
- flexgate/cli.py +935 -0
- flexgate/config.py +312 -0
- flexgate/doctor.py +241 -0
- flexgate/guardian.py +252 -0
- flexgate/healthcheck.py +197 -0
- flexgate/main.py +56 -0
- flexgate/migrate.py +138 -0
- flexgate/proxy.py +165 -0
- flexgate/registry.py +22 -0
- flexgate/router.py +75 -0
- flexgate/server.py +85 -0
- flexgate/service.py +1205 -0
- flexgate/settings.py +193 -0
- flexgate/sync.py +203 -0
- flexgate/update.py +188 -0
- flexgate-0.1.0.dist-info/METADATA +366 -0
- flexgate-0.1.0.dist-info/RECORD +21 -0
- flexgate-0.1.0.dist-info/WHEEL +4 -0
- flexgate-0.1.0.dist-info/entry_points.txt +2 -0
flexgate/settings.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from flexgate.config import (
|
|
11
|
+
ClaudeSettings,
|
|
12
|
+
GatewayConfig,
|
|
13
|
+
ProviderConfig,
|
|
14
|
+
RouteConfig,
|
|
15
|
+
load_config,
|
|
16
|
+
save_config,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
CLAUDE_DIR = os.path.expanduser("~/.claude")
|
|
20
|
+
SETTINGS_GLOB = os.path.join(CLAUDE_DIR, "settings.json*")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _scan_settings_files() -> list[tuple[str, str]]:
|
|
24
|
+
"""Return [(filepath, suffix), ...] for all ~/.claude/settings.json* files."""
|
|
25
|
+
results = []
|
|
26
|
+
for path in sorted(glob.glob(SETTINGS_GLOB)):
|
|
27
|
+
basename = os.path.basename(path)
|
|
28
|
+
# settings.json → suffix "default", settings.json.zai → "zai"
|
|
29
|
+
if basename == "settings.json":
|
|
30
|
+
suffix = "default"
|
|
31
|
+
else:
|
|
32
|
+
suffix = basename.replace("settings.json.", "", 1)
|
|
33
|
+
# Skip backup files from our own apply command
|
|
34
|
+
if suffix.startswith("bak"):
|
|
35
|
+
continue
|
|
36
|
+
results.append((path, suffix))
|
|
37
|
+
return results
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_provider(filepath: str) -> dict | None:
|
|
41
|
+
"""Extract base_url and api_key from a settings.json file."""
|
|
42
|
+
try:
|
|
43
|
+
with open(filepath) as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
except (json.JSONDecodeError, OSError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
env = data.get("env", {})
|
|
49
|
+
base_url = env.get("ANTHROPIC_BASE_URL", "")
|
|
50
|
+
api_key = env.get("ANTHROPIC_AUTH_TOKEN", "")
|
|
51
|
+
|
|
52
|
+
if not base_url or not api_key:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return {"base_url": base_url.rstrip("/"), "api_key": api_key}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def settings_import(config_path: str) -> None:
|
|
59
|
+
"""Read ~/.claude/settings.json* files and populate config.yaml providers."""
|
|
60
|
+
files = _scan_settings_files()
|
|
61
|
+
if not files:
|
|
62
|
+
print("No settings files found in ~/.claude/")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Try to load existing config, or start fresh
|
|
66
|
+
try:
|
|
67
|
+
cfg = load_config(config_path)
|
|
68
|
+
except (FileNotFoundError, ValueError):
|
|
69
|
+
cfg = GatewayConfig()
|
|
70
|
+
|
|
71
|
+
print("Found settings files:")
|
|
72
|
+
for filepath, suffix in files:
|
|
73
|
+
provider = _extract_provider(filepath)
|
|
74
|
+
status = "OK" if provider else "SKIPPED (no credentials)"
|
|
75
|
+
print(f" {filepath} [{suffix}] — {status}")
|
|
76
|
+
|
|
77
|
+
print()
|
|
78
|
+
|
|
79
|
+
# Map suffixes to provider names
|
|
80
|
+
provider_map: dict[str, dict] = {}
|
|
81
|
+
for filepath, suffix in files:
|
|
82
|
+
provider = _extract_provider(filepath)
|
|
83
|
+
if not provider:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if suffix == "default":
|
|
87
|
+
# Try to infer provider name from base_url
|
|
88
|
+
name = _infer_provider_name(provider["base_url"])
|
|
89
|
+
else:
|
|
90
|
+
name = suffix
|
|
91
|
+
|
|
92
|
+
provider_map[name] = provider
|
|
93
|
+
|
|
94
|
+
if not provider_map:
|
|
95
|
+
print("No valid provider credentials found in settings files.")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Merge into config
|
|
99
|
+
for name, prov in provider_map.items():
|
|
100
|
+
if name in cfg.providers:
|
|
101
|
+
cfg.providers[name].base_url = prov["base_url"]
|
|
102
|
+
cfg.providers[name].api_key = prov["api_key"]
|
|
103
|
+
print(f" Updated provider: {name}")
|
|
104
|
+
else:
|
|
105
|
+
cfg.providers[name] = ProviderConfig(
|
|
106
|
+
name=name,
|
|
107
|
+
base_url=prov["base_url"],
|
|
108
|
+
api_key=prov["api_key"],
|
|
109
|
+
)
|
|
110
|
+
print(f" Added provider: {name}")
|
|
111
|
+
|
|
112
|
+
save_config(cfg, config_path)
|
|
113
|
+
print(f"\nConfig saved to {config_path}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _infer_provider_name(base_url: str) -> str:
|
|
117
|
+
"""Guess provider name from the base_url."""
|
|
118
|
+
url_lower = base_url.lower()
|
|
119
|
+
if "z.ai" in url_lower:
|
|
120
|
+
return "zai"
|
|
121
|
+
if "minimax" in url_lower:
|
|
122
|
+
return "minimax"
|
|
123
|
+
if "openai" in url_lower:
|
|
124
|
+
return "openai"
|
|
125
|
+
if "anthropic" in url_lower:
|
|
126
|
+
return "anthropic"
|
|
127
|
+
# Fallback: use hostname
|
|
128
|
+
from urllib.parse import urlparse
|
|
129
|
+
parsed = urlparse(base_url)
|
|
130
|
+
return parsed.hostname or "unknown"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_new_settings(cfg: GatewayConfig, existing: dict, auth_token: str) -> dict:
|
|
134
|
+
"""Return the new settings dict: preserved non-env fields + gateway env block."""
|
|
135
|
+
new_settings: dict = {}
|
|
136
|
+
|
|
137
|
+
for key in ("permissions", "skipDangerousModePermissionPrompt", "skipAutoPermissionPrompt"):
|
|
138
|
+
if key in existing:
|
|
139
|
+
new_settings[key] = existing[key]
|
|
140
|
+
|
|
141
|
+
cs = cfg.claude_settings
|
|
142
|
+
new_settings["env"] = {
|
|
143
|
+
"ANTHROPIC_BASE_URL": f"http://{cfg.server.host}:{cfg.server.port}",
|
|
144
|
+
"ANTHROPIC_AUTH_TOKEN": auth_token,
|
|
145
|
+
"API_TIMEOUT_MS": str(cs.api_timeout_ms),
|
|
146
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": cs.default_opus_model,
|
|
147
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": cs.default_sonnet_model,
|
|
148
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": cs.default_haiku_model,
|
|
149
|
+
}
|
|
150
|
+
return new_settings
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def settings_apply(config_path: str, *, auth_token: str | None = None) -> None:
|
|
154
|
+
"""Apply config.yaml to ~/.claude/settings.json (with backup).
|
|
155
|
+
|
|
156
|
+
auth_token: value to write as ANTHROPIC_AUTH_TOKEN. If None, defaults to "gateway"
|
|
157
|
+
(preserves legacy behavior of this command for callers that do not pass a token).
|
|
158
|
+
"""
|
|
159
|
+
cfg = load_config(config_path)
|
|
160
|
+
effective_token = "gateway" if auth_token is None else auth_token
|
|
161
|
+
|
|
162
|
+
settings_file = os.path.join(CLAUDE_DIR, "settings.json")
|
|
163
|
+
if not os.path.exists(settings_file):
|
|
164
|
+
print(f"Warning: {settings_file} does not exist, creating new file.")
|
|
165
|
+
|
|
166
|
+
# Backup existing settings.json
|
|
167
|
+
if os.path.exists(settings_file):
|
|
168
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
169
|
+
backup_path = os.path.join(CLAUDE_DIR, f"settings.json.bak.{timestamp}")
|
|
170
|
+
shutil.copy2(settings_file, backup_path)
|
|
171
|
+
print(f"Backup: {backup_path}")
|
|
172
|
+
|
|
173
|
+
# Read existing file (for field preservation)
|
|
174
|
+
existing: dict = {}
|
|
175
|
+
if os.path.exists(settings_file):
|
|
176
|
+
try:
|
|
177
|
+
with open(settings_file) as f:
|
|
178
|
+
existing = json.load(f)
|
|
179
|
+
except (json.JSONDecodeError, OSError):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
new_settings = _build_new_settings(cfg, existing, effective_token)
|
|
183
|
+
|
|
184
|
+
with open(settings_file, "w") as f:
|
|
185
|
+
json.dump(new_settings, f, indent=2)
|
|
186
|
+
f.write("\n")
|
|
187
|
+
|
|
188
|
+
cs = cfg.claude_settings
|
|
189
|
+
print(f"Applied: {settings_file}")
|
|
190
|
+
print(f" ANTHROPIC_BASE_URL = http://{cfg.server.host}:{cfg.server.port}")
|
|
191
|
+
print(f" OPUS_MODEL = {cs.default_opus_model}")
|
|
192
|
+
print(f" SONNET_MODEL = {cs.default_sonnet_model}")
|
|
193
|
+
print(f" HAIKU_MODEL = {cs.default_haiku_model}")
|
flexgate/sync.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from flexgate.config import ProviderConfig, load_config, save_config
|
|
9
|
+
from flexgate.registry import KNOWN_BASE_URLS
|
|
10
|
+
|
|
11
|
+
# Each provider is a folder under this path, named exactly after the provider,
|
|
12
|
+
# containing an API_KEY secret:
|
|
13
|
+
# /providers/minimax-tmy/API_KEY
|
|
14
|
+
PROVIDERS_FOLDER = "/providers"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _match_base_url(name: str, table: dict[str, str]) -> str | None:
|
|
18
|
+
"""Longest known prefix of `name` (dash boundary) → its base_url."""
|
|
19
|
+
for prefix in sorted(table, key=len, reverse=True):
|
|
20
|
+
if name == prefix or name.startswith(prefix + "-"):
|
|
21
|
+
return table[prefix]
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mask_key(key: str) -> str:
|
|
26
|
+
if len(key) <= 8:
|
|
27
|
+
return "***"
|
|
28
|
+
return key[:4] + "***" + key[-4:]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run_infisical(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
return subprocess.run(
|
|
33
|
+
["infisical", *args],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=60,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_prerequisites() -> None:
|
|
41
|
+
if shutil.which("infisical") is None:
|
|
42
|
+
print("infisical CLI not found in PATH.")
|
|
43
|
+
print("Install it first: https://infisical.com/docs/cli/overview")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
proc = _run_infisical(["login", "status"])
|
|
47
|
+
if proc.returncode != 0:
|
|
48
|
+
print("infisical CLI is not authenticated.")
|
|
49
|
+
print("Run 'infisical login' first.")
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_json(proc: subprocess.CompletedProcess[str], what: str) -> list:
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(proc.stdout)
|
|
56
|
+
except json.JSONDecodeError as e:
|
|
57
|
+
print(f"Could not parse infisical {what} output: {e}")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
return data if isinstance(data, list) else []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _list_provider_folders(project_id: str, env: str) -> list[str]:
|
|
63
|
+
"""List folder names under PROVIDERS_FOLDER; each name is a provider."""
|
|
64
|
+
proc = _run_infisical([
|
|
65
|
+
"secrets", "folders", "get",
|
|
66
|
+
"--path", PROVIDERS_FOLDER,
|
|
67
|
+
"-o", "json",
|
|
68
|
+
f"--projectId={project_id}",
|
|
69
|
+
f"--env={env}",
|
|
70
|
+
"--silent",
|
|
71
|
+
])
|
|
72
|
+
if proc.returncode != 0:
|
|
73
|
+
print(f"Could not list folders under {PROVIDERS_FOLDER}:")
|
|
74
|
+
print((proc.stderr or proc.stdout).strip())
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
return [str(f["folderName"]) for f in _parse_json(proc, "folders") if "folderName" in f]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fetch_folder_secrets(project_id: str, env: str, folder: str) -> dict[str, str]:
|
|
80
|
+
proc = _run_infisical([
|
|
81
|
+
"export",
|
|
82
|
+
"--format=json",
|
|
83
|
+
f"--env={env}",
|
|
84
|
+
f"--projectId={project_id}",
|
|
85
|
+
f"--path={PROVIDERS_FOLDER}/{folder}",
|
|
86
|
+
"--expand=false",
|
|
87
|
+
"--silent",
|
|
88
|
+
])
|
|
89
|
+
if proc.returncode != 0:
|
|
90
|
+
print(f"Failed to fetch secrets from {PROVIDERS_FOLDER}/{folder}:")
|
|
91
|
+
print((proc.stderr or proc.stdout).strip())
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
secrets: dict[str, str] = {}
|
|
95
|
+
for entry in _parse_json(proc, "export"):
|
|
96
|
+
if isinstance(entry, dict) and "key" in entry:
|
|
97
|
+
secrets[str(entry["key"])] = str(entry.get("value", ""))
|
|
98
|
+
return secrets
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sync_pull(config_path: str, dry_run: bool = False) -> None:
|
|
102
|
+
"""Pull provider api_keys from Infisical into the flexgate config.
|
|
103
|
+
|
|
104
|
+
Provider mapping is derived from the Infisical layout: a folder named
|
|
105
|
+
after the provider under /providers, holding an API_KEY secret (an
|
|
106
|
+
optional MODELS secret lists comma-separated model names). Folders with
|
|
107
|
+
no matching provider in the config are auto-imported when their name
|
|
108
|
+
starts with a known prefix (see KNOWN_BASE_URLS in registry.py);
|
|
109
|
+
otherwise a warning is printed.
|
|
110
|
+
"""
|
|
111
|
+
_check_prerequisites()
|
|
112
|
+
|
|
113
|
+
config = load_config(config_path)
|
|
114
|
+
inf = config.infisical
|
|
115
|
+
if not inf.project_id:
|
|
116
|
+
print("No Infisical project configured.")
|
|
117
|
+
print(f"Add an 'infisical' section to {config_path}:")
|
|
118
|
+
print()
|
|
119
|
+
print(" infisical:")
|
|
120
|
+
print(" project_id: \"<your-project-id>\"")
|
|
121
|
+
print(" env: \"dev\"")
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
|
|
124
|
+
if not config.providers:
|
|
125
|
+
print("No providers configured; nothing to sync.")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
print(f"Fetching secrets from Infisical (project {inf.project_id}, env {inf.env})...")
|
|
129
|
+
folders = _list_provider_folders(inf.project_id, inf.env)
|
|
130
|
+
|
|
131
|
+
updated: list[str] = []
|
|
132
|
+
unchanged: list[str] = []
|
|
133
|
+
missing: list[str] = []
|
|
134
|
+
|
|
135
|
+
for name, prov in config.providers.items():
|
|
136
|
+
if name not in folders:
|
|
137
|
+
missing.append(name)
|
|
138
|
+
continue
|
|
139
|
+
new_key = _fetch_folder_secrets(inf.project_id, inf.env, name).get("API_KEY", "")
|
|
140
|
+
if new_key and new_key != prov.api_key:
|
|
141
|
+
prov.api_key = new_key
|
|
142
|
+
updated.append(name)
|
|
143
|
+
else:
|
|
144
|
+
unchanged.append(name)
|
|
145
|
+
|
|
146
|
+
# Providers that exist in Infisical but not in the local config: try to
|
|
147
|
+
# auto-import them by matching the folder name against known prefixes.
|
|
148
|
+
base_url_table = dict(KNOWN_BASE_URLS)
|
|
149
|
+
base_url_table.update({n: p.base_url for n, p in config.providers.items()})
|
|
150
|
+
|
|
151
|
+
imported: list[str] = []
|
|
152
|
+
unknown: list[str] = []
|
|
153
|
+
|
|
154
|
+
for name in [f for f in folders if f not in config.providers]:
|
|
155
|
+
base_url = _match_base_url(name, base_url_table)
|
|
156
|
+
if base_url is None:
|
|
157
|
+
unknown.append(name)
|
|
158
|
+
continue
|
|
159
|
+
secrets = _fetch_folder_secrets(inf.project_id, inf.env, name)
|
|
160
|
+
api_key = secrets.get("API_KEY", "")
|
|
161
|
+
if not api_key:
|
|
162
|
+
unknown.append(name)
|
|
163
|
+
continue
|
|
164
|
+
models = [m.strip() for m in secrets.get("MODELS", "").split(",") if m.strip()]
|
|
165
|
+
config.providers[name] = ProviderConfig(
|
|
166
|
+
name=name,
|
|
167
|
+
base_url=base_url,
|
|
168
|
+
api_key=api_key,
|
|
169
|
+
available_models=models,
|
|
170
|
+
)
|
|
171
|
+
imported.append(name)
|
|
172
|
+
|
|
173
|
+
for name in updated:
|
|
174
|
+
print(f" {name:16s} updated (key: {_mask_key(config.providers[name].api_key)})")
|
|
175
|
+
for name in imported:
|
|
176
|
+
print(f" {name:16s} imported (new provider, base_url: {config.providers[name].base_url})")
|
|
177
|
+
for name in unchanged:
|
|
178
|
+
print(f" {name:16s} unchanged")
|
|
179
|
+
for name in missing:
|
|
180
|
+
print(f" {name:16s} no folder {PROVIDERS_FOLDER}/{name} in Infisical")
|
|
181
|
+
for name in unknown:
|
|
182
|
+
print(f" WARNING: {name} has no known prefix and was skipped.")
|
|
183
|
+
print(f" Add it to the config manually, or register its prefix in flexgate/registry.py.")
|
|
184
|
+
|
|
185
|
+
changed = updated or imported
|
|
186
|
+
if not changed:
|
|
187
|
+
print("\nAll keys are up to date.")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if dry_run:
|
|
191
|
+
n = len(updated) + len(imported)
|
|
192
|
+
print(f"\nDry run: {n} provider(s) would be updated/imported. No changes written.")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
save_config(config, config_path)
|
|
196
|
+
print(f"\nUpdated {len(updated)} key(s), imported {len(imported)} provider(s). Saved: {config_path}")
|
|
197
|
+
|
|
198
|
+
# Hot-reload the authoritative systemd service, if it is active.
|
|
199
|
+
from flexgate.service import reload_service_if_active
|
|
200
|
+
|
|
201
|
+
msg = reload_service_if_active(config_path)
|
|
202
|
+
if msg:
|
|
203
|
+
print(msg)
|
flexgate/update.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Package self-update via pip/pipx/uv and config migration.
|
|
2
|
+
|
|
3
|
+
``flexgate update`` handles both halves of an upgrade:
|
|
4
|
+
|
|
5
|
+
1. package — detect how flexgate was installed (pipx / uv tool / pip) and
|
|
6
|
+
upgrade to the latest release published on PyPI;
|
|
7
|
+
2. config — apply pending config.yaml schema migrations (with backup).
|
|
8
|
+
|
|
9
|
+
Use ``--check`` to only report what would change.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
from flexgate import __version__
|
|
22
|
+
from flexgate.migrate import CURRENT_CONFIG_VERSION, migrate_config_file, read_raw_config, detect_config_version, pending_steps
|
|
23
|
+
|
|
24
|
+
PYPI_JSON_URL = "https://pypi.org/pypi/flexgate/json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_version(text: str) -> tuple[int, ...]:
|
|
28
|
+
"""Parse '1.2.3' into (1, 2, 3); pre-release suffixes are ignored."""
|
|
29
|
+
parts = re.findall(r"\d+", text)
|
|
30
|
+
return tuple(int(p) for p in parts) if parts else (0,)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fetch_latest_version(timeout: float = 5.0) -> str | None:
|
|
34
|
+
"""Latest released version on PyPI, or None when unreachable/offline."""
|
|
35
|
+
try:
|
|
36
|
+
req = urllib.request.Request(PYPI_JSON_URL, headers={"Accept": "application/json"})
|
|
37
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
38
|
+
data = json.loads(resp.read())
|
|
39
|
+
return str(data["info"]["version"])
|
|
40
|
+
except Exception:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_installer() -> tuple[str, list[str]] | None:
|
|
45
|
+
"""Return (label, upgrade command) for the tool that installed flexgate."""
|
|
46
|
+
if shutil.which("pipx"):
|
|
47
|
+
out = subprocess.run(["pipx", "list", "--short"], capture_output=True, text=True).stdout
|
|
48
|
+
if any(line.split()[0] == "flexgate" for line in out.splitlines() if line.strip()):
|
|
49
|
+
return ("pipx", ["pipx", "upgrade", "flexgate"])
|
|
50
|
+
if shutil.which("uv"):
|
|
51
|
+
out = subprocess.run(["uv", "tool", "list"], capture_output=True, text=True).stdout
|
|
52
|
+
if any(line.split()[0] == "flexgate" for line in out.splitlines() if line.strip()):
|
|
53
|
+
return ("uv tool", ["uv", "tool", "upgrade", "flexgate"])
|
|
54
|
+
return ("pip", [sys.executable, "-m", "pip", "install", "--upgrade", "flexgate"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── cached background version check ───────────────────────────────
|
|
58
|
+
#
|
|
59
|
+
# Interactive commands (bare `flexgate`, `service status`) show a one-line
|
|
60
|
+
# upgrade hint when PyPI carries a newer release. The PyPI lookup is cached
|
|
61
|
+
# on disk so it hits the network at most once per UPDATE_CHECK_INTERVAL and
|
|
62
|
+
# never slows down or breaks the CLI (every failure is silent).
|
|
63
|
+
|
|
64
|
+
UPDATE_CHECK_INTERVAL = 24 * 3600 # seconds between PyPI lookups
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _update_check_cache_path() -> str:
|
|
68
|
+
import os
|
|
69
|
+
|
|
70
|
+
from flexgate.config import FLEXGATE_HOME
|
|
71
|
+
return os.path.join(FLEXGATE_HOME, "update-check.json")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def latest_version_cached(interval: float = UPDATE_CHECK_INTERVAL) -> str | None:
|
|
75
|
+
"""Latest PyPI version, cached on disk; None on any failure."""
|
|
76
|
+
import os
|
|
77
|
+
import time
|
|
78
|
+
|
|
79
|
+
path = _update_check_cache_path()
|
|
80
|
+
try:
|
|
81
|
+
with open(path) as f:
|
|
82
|
+
cache = json.load(f)
|
|
83
|
+
if time.time() - float(cache.get("checked_at", 0)) < interval:
|
|
84
|
+
return cache.get("latest") or None
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
latest = fetch_latest_version(timeout=1.5)
|
|
89
|
+
try:
|
|
90
|
+
tmp = path + ".tmp"
|
|
91
|
+
with open(tmp, "w") as f:
|
|
92
|
+
json.dump({"checked_at": time.time(), "latest": latest}, f)
|
|
93
|
+
os.replace(tmp, path)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return latest
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def update_notice() -> str | None:
|
|
100
|
+
"""One-line upgrade hint when PyPI has a newer release, else None."""
|
|
101
|
+
try:
|
|
102
|
+
latest = latest_version_cached()
|
|
103
|
+
except Exception:
|
|
104
|
+
return None
|
|
105
|
+
if latest and parse_version(latest) > parse_version(__version__):
|
|
106
|
+
return (f"A newer flexgate {latest} is available "
|
|
107
|
+
f"(installed {__version__}) — run: flexgate update")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _config_status(config_path: str) -> tuple[int, list[int]] | None:
|
|
112
|
+
"""(current_version, pending steps), or None if the config cannot be read."""
|
|
113
|
+
try:
|
|
114
|
+
data = read_raw_config(config_path)
|
|
115
|
+
except Exception:
|
|
116
|
+
return None
|
|
117
|
+
version = detect_config_version(data)
|
|
118
|
+
return version, pending_steps(version)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_update(config_path: str, *, check: bool = False, config_only: bool = False) -> int:
|
|
122
|
+
import os
|
|
123
|
+
|
|
124
|
+
print(f"flexgate {__version__} (config schema v{CURRENT_CONFIG_VERSION})")
|
|
125
|
+
failures = 0
|
|
126
|
+
|
|
127
|
+
# ── package update ────────────────────────────────────────────
|
|
128
|
+
latest = fetch_latest_version()
|
|
129
|
+
if latest is None:
|
|
130
|
+
print("\nPackage: could not reach PyPI — skipping package update check.")
|
|
131
|
+
else:
|
|
132
|
+
newer = parse_version(latest) > parse_version(__version__)
|
|
133
|
+
print(f"\nPackage: installed {__version__}, latest {latest}"
|
|
134
|
+
+ (" — update available" if newer else " — up to date"))
|
|
135
|
+
if newer and not config_only:
|
|
136
|
+
installer = detect_installer()
|
|
137
|
+
if installer is None:
|
|
138
|
+
print(" Could not detect how flexgate was installed; upgrade manually.")
|
|
139
|
+
failures += 1
|
|
140
|
+
else:
|
|
141
|
+
label, cmd = installer
|
|
142
|
+
if check:
|
|
143
|
+
print(f" Would run: {' '.join(cmd)}")
|
|
144
|
+
else:
|
|
145
|
+
print(f" Upgrading via {label}: {' '.join(cmd)}")
|
|
146
|
+
proc = subprocess.run(cmd)
|
|
147
|
+
if proc.returncode != 0:
|
|
148
|
+
print(f" Package upgrade failed (exit {proc.returncode}).")
|
|
149
|
+
failures += 1
|
|
150
|
+
else:
|
|
151
|
+
print(f" Upgraded to {latest}. Restart the service to use it:")
|
|
152
|
+
print(" flexgate service restart")
|
|
153
|
+
|
|
154
|
+
# ── config migration ──────────────────────────────────────────
|
|
155
|
+
print()
|
|
156
|
+
if not os.path.exists(config_path):
|
|
157
|
+
print(f"Config: {config_path} not found — nothing to migrate.")
|
|
158
|
+
return 1 if failures else 0
|
|
159
|
+
|
|
160
|
+
status = _config_status(config_path)
|
|
161
|
+
if status is None:
|
|
162
|
+
print(f"Config: could not parse {config_path} — run 'flexgate doctor' for details.")
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
version, steps = status
|
|
166
|
+
if not steps:
|
|
167
|
+
print(f"Config: schema v{version} is current — nothing to migrate.")
|
|
168
|
+
else:
|
|
169
|
+
print(f"Config: schema v{version} → v{CURRENT_CONFIG_VERSION}, {len(steps)} migration(s) pending.")
|
|
170
|
+
if check:
|
|
171
|
+
for step in steps:
|
|
172
|
+
print(f" Would apply v{step} → v{step + 1}")
|
|
173
|
+
else:
|
|
174
|
+
result = migrate_config_file(config_path)
|
|
175
|
+
for line in result.applied:
|
|
176
|
+
print(f" Applied {line}")
|
|
177
|
+
if result.backup_path:
|
|
178
|
+
print(f" Backup: {result.backup_path}")
|
|
179
|
+
print(f" Migrated: {config_path}")
|
|
180
|
+
|
|
181
|
+
# ── reload the running service with the migrated config ──────
|
|
182
|
+
if not check:
|
|
183
|
+
from flexgate.service import reload_service_if_active
|
|
184
|
+
msg = reload_service_if_active(config_path)
|
|
185
|
+
if msg:
|
|
186
|
+
print(f"\n{msg}")
|
|
187
|
+
|
|
188
|
+
return 1 if failures else 0
|