studio-console 1.3.4__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.
- VERSION +1 -0
- docker-compose.yml +282 -0
- nginx/studio.conf.template +89 -0
- studio_console/__init__.py +17 -0
- studio_console/__main__.py +3 -0
- studio_console/cli.py +176 -0
- studio_console/cloudflare/__init__.py +1 -0
- studio_console/cloudflare/cf_api.py +285 -0
- studio_console/cloudflare/cf_wizard.py +1549 -0
- studio_console/commands.py +3001 -0
- studio_console/commands_container.py +526 -0
- studio_console/commands_launch.py +684 -0
- studio_console/constants.py +184 -0
- studio_console/data/README.md +37 -0
- studio_console/data/known_baselines.json +3 -0
- studio_console/env.py +600 -0
- studio_console/major_version.py +257 -0
- studio_console/tui.py +412 -0
- studio_console/wizard.py +1953 -0
- studio_console-1.3.4.dist-info/METADATA +235 -0
- studio_console-1.3.4.dist-info/RECORD +26 -0
- studio_console-1.3.4.dist-info/WHEEL +4 -0
- studio_console-1.3.4.dist-info/entry_points.txt +2 -0
- studio_console-1.3.4.dist-info/licenses/LEGAL.md +60 -0
- studio_console-1.3.4.dist-info/licenses/LICENSE +112 -0
- templates/.env.example +76 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# studio_console/major_version.py
|
|
2
|
+
"""Major-version boundary detection.
|
|
3
|
+
|
|
4
|
+
Studio's API refuses to start when the database was migrated by a prior major
|
|
5
|
+
version (see Studio's api/scripts/bootstrap.py:_check_major_version_compatibility).
|
|
6
|
+
This module lets the console detect that condition pre-start, so install,
|
|
7
|
+
upgrade, and restore can block with a clear explanation instead of letting
|
|
8
|
+
the operator hit a generic "API not responding" timeout.
|
|
9
|
+
|
|
10
|
+
The bundled `data/known_baselines.json` maps each major version to its alembic
|
|
11
|
+
baseline revision(s). A major may have more than one baseline because Studio
|
|
12
|
+
periodically squashes-and-stamps its migration chain to a new baseline; each
|
|
13
|
+
historical baseline a restorable DB might sit on must be listed. It is re-synced
|
|
14
|
+
from Studio at console release time. Staleness is accepted: between a Studio
|
|
15
|
+
major release (or squash) and the next console release, prior-major detection
|
|
16
|
+
falls back to log-scraping the API container after a failed start.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
from importlib import resources
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .env import compose_cmd, read_env
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Possible outcomes of a major-boundary check.
|
|
31
|
+
# ok — DB is on the target major, or we can't prove otherwise
|
|
32
|
+
# prior_major — DB is on an older major than the target — BLOCK
|
|
33
|
+
# unknown_future — DB rev is a known baseline for a newer major — BLOCK
|
|
34
|
+
# fresh — alembic_version absent / postgres down — proceed
|
|
35
|
+
# indeterminate_tag — target tag is non-semver (latest, main, sha) — proceed with notice
|
|
36
|
+
Result = str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_SEMVER_RE = re.compile(r"^(\d+)\.\d+\.\d+")
|
|
40
|
+
_FATAL_RE = re.compile(r"FATAL: Studio cannot start\.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_baselines() -> dict[int, list[str]]:
|
|
44
|
+
"""Read the bundled {major: [baseline_revisions]} map. Empty dict on any error.
|
|
45
|
+
|
|
46
|
+
Accepts both the historical scalar form ({"1": "abc"}) and the list form
|
|
47
|
+
({"1": ["abc", "def"]}); scalars are normalized to single-element lists so a
|
|
48
|
+
major can carry multiple baselines after a squash-and-stamp.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
text = (
|
|
52
|
+
resources.files("studio_console.data")
|
|
53
|
+
.joinpath("known_baselines.json")
|
|
54
|
+
.read_text()
|
|
55
|
+
)
|
|
56
|
+
except (FileNotFoundError, ModuleNotFoundError, OSError):
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
raw = json.loads(text)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
return {}
|
|
62
|
+
out: dict[int, list[str]] = {}
|
|
63
|
+
for k, v in raw.items():
|
|
64
|
+
try:
|
|
65
|
+
major = int(k)
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
continue
|
|
68
|
+
revs = v if isinstance(v, list) else [v]
|
|
69
|
+
normalized = [str(r) for r in revs if r]
|
|
70
|
+
if normalized:
|
|
71
|
+
out[major] = normalized
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_target_major(version_tag: str) -> int | None:
|
|
76
|
+
"""Leading semver major from a tag, or None for non-semver (latest, main, sha)."""
|
|
77
|
+
if not version_tag:
|
|
78
|
+
return None
|
|
79
|
+
m = _SEMVER_RE.match(version_tag.strip())
|
|
80
|
+
return int(m.group(1)) if m else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_db_revision(env_file: Path, context: str) -> str | None:
|
|
84
|
+
"""Query alembic_version.version_num. Context-aware.
|
|
85
|
+
|
|
86
|
+
host — exec into the postgres compose service.
|
|
87
|
+
container — psql directly (postgres on localhost in Full,
|
|
88
|
+
/runpod external via env in Core).
|
|
89
|
+
|
|
90
|
+
Returns None for any of: postgres not running, DB absent, alembic_version
|
|
91
|
+
table absent, query failed. Callers cannot distinguish — they all mean
|
|
92
|
+
"no prior-major condition observable from here".
|
|
93
|
+
"""
|
|
94
|
+
env_data = read_env(env_file)
|
|
95
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
96
|
+
sql = "SELECT version_num FROM alembic_version LIMIT 1;"
|
|
97
|
+
if context == "host":
|
|
98
|
+
cmd = compose_cmd(env_file) + [
|
|
99
|
+
"exec", "-T", "postgres",
|
|
100
|
+
"psql", "-U", pg_user, "-d", "selfhost_studio", "-tAc", sql,
|
|
101
|
+
]
|
|
102
|
+
else:
|
|
103
|
+
cmd = ["psql", "-U", pg_user, "-d", "selfhost_studio", "-tAc", sql]
|
|
104
|
+
try:
|
|
105
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
106
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
107
|
+
return None
|
|
108
|
+
if result.returncode != 0:
|
|
109
|
+
return None
|
|
110
|
+
rev = result.stdout.strip()
|
|
111
|
+
return rev or None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def classify_revision(
|
|
115
|
+
rev: str | None,
|
|
116
|
+
target_major: int | None,
|
|
117
|
+
baselines: dict[int, list[str]] | None = None,
|
|
118
|
+
) -> tuple[Result, dict]:
|
|
119
|
+
"""Classify an alembic revision against the target major.
|
|
120
|
+
|
|
121
|
+
Pure: takes a revision string and a target major, returns the outcome.
|
|
122
|
+
Used both for the live-DB check (via classify_db) and the restore preflight
|
|
123
|
+
(where the revision comes from the backup file).
|
|
124
|
+
"""
|
|
125
|
+
if baselines is None:
|
|
126
|
+
baselines = load_baselines()
|
|
127
|
+
info: dict = {
|
|
128
|
+
"baselines": baselines,
|
|
129
|
+
"target_major": target_major,
|
|
130
|
+
"db_revision": rev,
|
|
131
|
+
"db_major": None,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if target_major is None:
|
|
135
|
+
return "indeterminate_tag", info
|
|
136
|
+
if rev is None:
|
|
137
|
+
return "fresh", info
|
|
138
|
+
|
|
139
|
+
db_major: int | None = None
|
|
140
|
+
for major, baseline_revs in baselines.items():
|
|
141
|
+
if rev in baseline_revs:
|
|
142
|
+
db_major = major
|
|
143
|
+
break
|
|
144
|
+
info["db_major"] = db_major
|
|
145
|
+
|
|
146
|
+
if db_major is None:
|
|
147
|
+
# Non-baseline revision. Could be a normal mid-chain rev (fine) or an
|
|
148
|
+
# unknown-future rev (not fine). Without the running API's full chain
|
|
149
|
+
# we can't tell — defer to API's own guardrail. Don't block here.
|
|
150
|
+
return "ok", info
|
|
151
|
+
if db_major < target_major:
|
|
152
|
+
return "prior_major", info
|
|
153
|
+
if db_major > target_major:
|
|
154
|
+
return "unknown_future", info
|
|
155
|
+
return "ok", info
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def classify_db(env_file: Path, target_major: int | None, context: str) -> tuple[Result, dict]:
|
|
159
|
+
"""Read the live DB revision and classify it. Thin wrapper over classify_revision."""
|
|
160
|
+
return classify_revision(read_db_revision(env_file, context), target_major)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render_block(result: Result, info: dict, action: str) -> None:
|
|
164
|
+
"""Print the shared major-boundary block. No-op for ok/fresh/indeterminate_tag."""
|
|
165
|
+
from .tui import _bold, _yellow
|
|
166
|
+
|
|
167
|
+
old = info.get("db_major")
|
|
168
|
+
new = info.get("target_major")
|
|
169
|
+
rev = info.get("db_revision") or "unknown"
|
|
170
|
+
|
|
171
|
+
if result == "prior_major":
|
|
172
|
+
print()
|
|
173
|
+
print(_yellow(_bold("⚠ Studio major-version boundary")))
|
|
174
|
+
print()
|
|
175
|
+
print(f" This database was migrated by Studio v{old} (revision {rev}).")
|
|
176
|
+
print(f" You are trying to {action} Studio v{new}.")
|
|
177
|
+
print()
|
|
178
|
+
print(f" Studio v{new} cannot start against a v{old} database, and")
|
|
179
|
+
print(f" no automated migration tool exists yet.")
|
|
180
|
+
print()
|
|
181
|
+
print(f" To proceed:")
|
|
182
|
+
print(f" • Stay on v{old}: set SHS_STUDIO_VERSION to a v{old} tag in")
|
|
183
|
+
print(f" ~/.studio/.env, then run 'studio-console start'.")
|
|
184
|
+
print(f" • Or restore a backup taken under v{new}.")
|
|
185
|
+
print()
|
|
186
|
+
elif result == "unknown_future":
|
|
187
|
+
print()
|
|
188
|
+
print(_yellow(_bold("⚠ Unknown database schema")))
|
|
189
|
+
print()
|
|
190
|
+
print(f" The database is at revision {rev}, which this Studio version")
|
|
191
|
+
print(f" (v{new}) doesn't recognize.")
|
|
192
|
+
print()
|
|
193
|
+
print(f" The database was likely migrated by a NEWER Studio version")
|
|
194
|
+
print(f" than the one you are about to run.")
|
|
195
|
+
print()
|
|
196
|
+
print(f" To proceed: reinstall the newer Studio image, or restore a")
|
|
197
|
+
print(f" backup compatible with v{new}.")
|
|
198
|
+
print()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def indeterminate_notice(tag: str) -> None:
|
|
202
|
+
"""Print the one-line notice when SHS_STUDIO_VERSION isn't semver."""
|
|
203
|
+
from .tui import warn
|
|
204
|
+
|
|
205
|
+
warn(
|
|
206
|
+
f"Cannot determine target major from tag '{tag}' — "
|
|
207
|
+
f"skipping major-version compatibility check."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def scrape_guardrail_failure(env_file: Path, context: str) -> bool:
|
|
212
|
+
"""True if recent API logs show the bootstrap guardrail FATAL.
|
|
213
|
+
|
|
214
|
+
host — `docker compose logs api`.
|
|
215
|
+
container — `supervisorctl tail api stdout` (bootstrap.py prints to stdout).
|
|
216
|
+
/runpod
|
|
217
|
+
|
|
218
|
+
Backstop for when the bundled baselines map is stale (a Studio major
|
|
219
|
+
shipped after this console release). Used by config_menu and cmd_health
|
|
220
|
+
to distinguish "API down: prior-major" from "API down: unknown".
|
|
221
|
+
"""
|
|
222
|
+
if context == "host":
|
|
223
|
+
cmd = compose_cmd(env_file) + ["logs", "--tail=50", "--no-color", "api"]
|
|
224
|
+
else:
|
|
225
|
+
# supervisorctl tail returns the most recent bytes for the given channel.
|
|
226
|
+
# Bootstrap's FATAL goes to stdout via print(); -10000 covers the typical
|
|
227
|
+
# crash-loop window without unbounded reads.
|
|
228
|
+
cmd = ["supervisorctl", "tail", "-10000", "api", "stdout"]
|
|
229
|
+
try:
|
|
230
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
231
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
232
|
+
return False
|
|
233
|
+
haystack = (result.stdout or "") + (result.stderr or "")
|
|
234
|
+
return bool(_FATAL_RE.search(haystack))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_and_block(
|
|
238
|
+
env_file: Path,
|
|
239
|
+
target_major: int | None,
|
|
240
|
+
action: str,
|
|
241
|
+
context: str,
|
|
242
|
+
target_tag: str = "",
|
|
243
|
+
) -> bool:
|
|
244
|
+
"""Run the live-DB check and render output. Return True if the caller should block.
|
|
245
|
+
|
|
246
|
+
Returns False for ok / fresh (proceed normally) and for indeterminate_tag
|
|
247
|
+
(after printing the notice). Returns True for prior_major / unknown_future
|
|
248
|
+
(after printing the shared block) — caller must not proceed.
|
|
249
|
+
"""
|
|
250
|
+
result, info = classify_db(env_file, target_major, context)
|
|
251
|
+
if result == "indeterminate_tag":
|
|
252
|
+
indeterminate_notice(target_tag)
|
|
253
|
+
return False
|
|
254
|
+
if result in ("prior_major", "unknown_future"):
|
|
255
|
+
render_block(result, info, action)
|
|
256
|
+
return True
|
|
257
|
+
return False
|
studio_console/tui.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# studio_console/tui.py
|
|
2
|
+
"""Generic terminal UI primitives - colors, output helpers, menu widgets, prompts.
|
|
3
|
+
|
|
4
|
+
This module knows nothing about Studio, components, or .env files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# TTY detection
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
_IS_TTY = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Color helpers
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _c(code: str, text: str) -> str:
|
|
22
|
+
if not _IS_TTY:
|
|
23
|
+
return text
|
|
24
|
+
return f"\033[{code}m{text}\033[0m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _red(t: str) -> str:
|
|
28
|
+
return _c("0;31", t)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _green(t: str) -> str:
|
|
32
|
+
return _c("0;32", t)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _yellow(t: str) -> str:
|
|
36
|
+
return _c("1;33", t)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cyan(t: str) -> str:
|
|
40
|
+
return _c("0;36", t)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bold(t: str) -> str:
|
|
44
|
+
return _c("1", t)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dim(t: str) -> str:
|
|
48
|
+
return _c("2", t)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Output helpers
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def info(msg: str) -> None:
|
|
57
|
+
print(f"{_cyan('▸')} {msg}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ok(msg: str) -> None:
|
|
61
|
+
print(f"{_green('✓')} {msg}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def warn(msg: str) -> None:
|
|
65
|
+
print(f"{_yellow('!')} {msg}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def warn_header(msg: str) -> None:
|
|
69
|
+
bar = "━" * 52
|
|
70
|
+
print()
|
|
71
|
+
print(_yellow(_bold(bar)))
|
|
72
|
+
print(_yellow(_bold(f"{msg}")))
|
|
73
|
+
print(_yellow(_bold(bar)))
|
|
74
|
+
print()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def error(msg: str) -> None:
|
|
78
|
+
print(f"{_red('✗')} {msg}", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fatal(msg: str) -> NoReturn:
|
|
82
|
+
error(msg)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def heading(msg: str) -> None:
|
|
87
|
+
bar = "━" * 52
|
|
88
|
+
print()
|
|
89
|
+
print(_green(_bold(bar)))
|
|
90
|
+
print(_green(_bold(f" {msg}")))
|
|
91
|
+
print(_green(_bold(bar)))
|
|
92
|
+
print()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Navigation exceptions
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class NavBack(Exception):
|
|
101
|
+
"""Raised when user selects Back in a menu."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NavExit(Exception):
|
|
105
|
+
"""Raised when user selects Exit in a menu."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Key constants
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
_ITEM_KEYS = "123456789ABCDEFGHIJKLMNOPQSTUVWYZ"
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Low-level terminal helpers
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_key() -> str:
|
|
120
|
+
"""Read a single keypress. Returns special names for arrow keys."""
|
|
121
|
+
import tty
|
|
122
|
+
import termios
|
|
123
|
+
|
|
124
|
+
fd = sys.stdin.fileno()
|
|
125
|
+
old = termios.tcgetattr(fd)
|
|
126
|
+
try:
|
|
127
|
+
tty.setraw(fd)
|
|
128
|
+
ch = sys.stdin.read(1)
|
|
129
|
+
if ch == "\x1b":
|
|
130
|
+
seq = sys.stdin.read(2)
|
|
131
|
+
return {"[A": "up", "[B": "down", "[C": "right", "[D": "left"}.get(
|
|
132
|
+
seq, "esc"
|
|
133
|
+
)
|
|
134
|
+
if ch in ("\r", "\n"):
|
|
135
|
+
return "enter"
|
|
136
|
+
if ch == " ":
|
|
137
|
+
return "space"
|
|
138
|
+
if ch == "\x03": # Ctrl-C
|
|
139
|
+
raise KeyboardInterrupt
|
|
140
|
+
if ch == "\x04": # Ctrl-D
|
|
141
|
+
raise EOFError
|
|
142
|
+
return ch
|
|
143
|
+
finally:
|
|
144
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _clear_lines(n: int) -> None:
|
|
148
|
+
"""Move cursor up n lines and clear them."""
|
|
149
|
+
for _ in range(n):
|
|
150
|
+
sys.stdout.write("\033[A\033[2K")
|
|
151
|
+
sys.stdout.flush()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Interactive menus
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _interactive_multi(
|
|
160
|
+
prompt: str,
|
|
161
|
+
options: list[str],
|
|
162
|
+
selected: set[int] | None = None,
|
|
163
|
+
required: bool = False,
|
|
164
|
+
nav: bool = True,
|
|
165
|
+
) -> list[int]:
|
|
166
|
+
"""Multi-select with arrow keys and space to toggle.
|
|
167
|
+
|
|
168
|
+
Returns list of selected 0-based indices.
|
|
169
|
+
When *nav* is True, Back (9) and Exit (0) are available.
|
|
170
|
+
"""
|
|
171
|
+
if not _IS_TTY:
|
|
172
|
+
# Fallback for non-interactive
|
|
173
|
+
return _fallback_multi(prompt, options, selected)
|
|
174
|
+
|
|
175
|
+
# Append nav item if enabled
|
|
176
|
+
return_idx = None
|
|
177
|
+
n_options = len(options)
|
|
178
|
+
if nav:
|
|
179
|
+
options = list(options) # don't mutate caller's list
|
|
180
|
+
return_idx = n_options
|
|
181
|
+
options.append("← Back")
|
|
182
|
+
|
|
183
|
+
sel = set(selected) if selected else set()
|
|
184
|
+
cursor = 0
|
|
185
|
+
rendered_lines = 0
|
|
186
|
+
total = len(options)
|
|
187
|
+
|
|
188
|
+
while True:
|
|
189
|
+
# Clear previous render
|
|
190
|
+
if rendered_lines:
|
|
191
|
+
_clear_lines(rendered_lines)
|
|
192
|
+
|
|
193
|
+
lines: list[str] = []
|
|
194
|
+
lines.append(f"{_cyan('▸')} {prompt}")
|
|
195
|
+
lines.append(f" {_dim('↑↓=navigate space=toggle enter=confirm')}")
|
|
196
|
+
for i, opt in enumerate(options):
|
|
197
|
+
label = _ITEM_KEYS[i] if i < len(_ITEM_KEYS) else " "
|
|
198
|
+
num = _dim(f"{label}.")
|
|
199
|
+
if i == return_idx:
|
|
200
|
+
# Return is not toggleable - show like single-select
|
|
201
|
+
pointer = "→" if i == cursor else " "
|
|
202
|
+
lines.append(f" {pointer} {num} {_dim('○')} {opt}")
|
|
203
|
+
else:
|
|
204
|
+
check = _green("●") if i in sel else _dim("○")
|
|
205
|
+
pointer = "→" if i == cursor else " "
|
|
206
|
+
lines.append(f" {pointer} {num} {check} {opt}")
|
|
207
|
+
|
|
208
|
+
if required and not sel:
|
|
209
|
+
lines.append(f" {_dim('(select at least one)')}")
|
|
210
|
+
|
|
211
|
+
rendered_lines = len(lines)
|
|
212
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
213
|
+
sys.stdout.flush()
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
key = _read_key()
|
|
217
|
+
except (KeyboardInterrupt, EOFError):
|
|
218
|
+
print()
|
|
219
|
+
raise KeyboardInterrupt
|
|
220
|
+
|
|
221
|
+
if key == "up":
|
|
222
|
+
cursor = (cursor - 1) % total
|
|
223
|
+
elif key == "down":
|
|
224
|
+
cursor = (cursor + 1) % total
|
|
225
|
+
elif key == "space":
|
|
226
|
+
if cursor != return_idx:
|
|
227
|
+
if cursor in sel:
|
|
228
|
+
sel.discard(cursor)
|
|
229
|
+
else:
|
|
230
|
+
sel.add(cursor)
|
|
231
|
+
elif key == "enter":
|
|
232
|
+
if cursor == return_idx:
|
|
233
|
+
raise NavBack
|
|
234
|
+
if required and not sel:
|
|
235
|
+
continue
|
|
236
|
+
return sorted(sel)
|
|
237
|
+
elif key.upper() == "N":
|
|
238
|
+
sel.clear()
|
|
239
|
+
elif key.upper() in _ITEM_KEYS:
|
|
240
|
+
idx = _ITEM_KEYS.index(key.upper())
|
|
241
|
+
if idx == return_idx:
|
|
242
|
+
raise NavBack
|
|
243
|
+
elif idx < n_options:
|
|
244
|
+
cursor = idx
|
|
245
|
+
if idx in sel:
|
|
246
|
+
sel.discard(idx)
|
|
247
|
+
else:
|
|
248
|
+
sel.add(idx)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _interactive_single(
|
|
252
|
+
prompt: str,
|
|
253
|
+
options: list[str],
|
|
254
|
+
default: int = 0,
|
|
255
|
+
nav: bool = True,
|
|
256
|
+
) -> int:
|
|
257
|
+
"""Single-select with arrow keys. Returns 0-based index.
|
|
258
|
+
|
|
259
|
+
When *nav* is True (default), Back (9) and Exit (0) are appended.
|
|
260
|
+
Back raises ``NavBack``; Exit raises ``NavExit``.
|
|
261
|
+
Set *nav=False* for menus that manage their own Back/Exit (config menu).
|
|
262
|
+
"""
|
|
263
|
+
if not _IS_TTY:
|
|
264
|
+
return _fallback_single(prompt, options, default)
|
|
265
|
+
|
|
266
|
+
# Append nav items if enabled
|
|
267
|
+
return_idx = None
|
|
268
|
+
if nav:
|
|
269
|
+
options = list(options) # don't mutate caller's list
|
|
270
|
+
return_idx = len(options)
|
|
271
|
+
options.append("← Back")
|
|
272
|
+
|
|
273
|
+
cursor = default
|
|
274
|
+
rendered_lines = 0
|
|
275
|
+
total = len(options)
|
|
276
|
+
|
|
277
|
+
while True:
|
|
278
|
+
if rendered_lines:
|
|
279
|
+
_clear_lines(rendered_lines)
|
|
280
|
+
|
|
281
|
+
lines: list[str] = []
|
|
282
|
+
lines.append(f"{_cyan('▸')} {prompt}")
|
|
283
|
+
lines.append(f" {_dim('↑↓=navigate enter=select')}")
|
|
284
|
+
for i, opt in enumerate(options):
|
|
285
|
+
label = _ITEM_KEYS[i] if i < len(_ITEM_KEYS) else " "
|
|
286
|
+
num = _dim(f"{label}.")
|
|
287
|
+
if i == cursor:
|
|
288
|
+
lines.append(f" → {num} {_green('●')} {_bold(opt)}")
|
|
289
|
+
else:
|
|
290
|
+
lines.append(f" {num} {_dim('○')} {opt}")
|
|
291
|
+
|
|
292
|
+
rendered_lines = len(lines)
|
|
293
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
294
|
+
sys.stdout.flush()
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
key = _read_key()
|
|
298
|
+
except (KeyboardInterrupt, EOFError):
|
|
299
|
+
print()
|
|
300
|
+
raise KeyboardInterrupt
|
|
301
|
+
|
|
302
|
+
if key == "up":
|
|
303
|
+
cursor = (cursor - 1) % total
|
|
304
|
+
elif key == "down":
|
|
305
|
+
cursor = (cursor + 1) % total
|
|
306
|
+
elif key == "enter":
|
|
307
|
+
if cursor == return_idx:
|
|
308
|
+
raise NavBack
|
|
309
|
+
return cursor
|
|
310
|
+
elif key.upper() in _ITEM_KEYS:
|
|
311
|
+
idx = _ITEM_KEYS.index(key.upper())
|
|
312
|
+
if idx < total:
|
|
313
|
+
if idx == return_idx:
|
|
314
|
+
raise NavBack
|
|
315
|
+
return idx
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _interactive_yn(prompt: str, default: bool = True, nav: bool = True) -> bool:
|
|
319
|
+
"""Yes/no with arrow keys."""
|
|
320
|
+
options = ["Yes", "No"]
|
|
321
|
+
result = _interactive_single(prompt, options, default=0 if default else 1, nav=nav)
|
|
322
|
+
return result == 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Fallbacks for non-TTY (pipes, CI)
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _fallback_multi(
|
|
331
|
+
prompt: str, options: list[str], selected: set[int] | None = None
|
|
332
|
+
) -> list[int]:
|
|
333
|
+
print(f"\n{_cyan('▸')} {prompt}")
|
|
334
|
+
for i, opt in enumerate(options, 1):
|
|
335
|
+
marker = " *" if selected and (i - 1) in selected else ""
|
|
336
|
+
print(f" {_bold(str(i))}. {opt}{marker}")
|
|
337
|
+
while True:
|
|
338
|
+
raw = _prompt("Enter numbers (comma-separated)")
|
|
339
|
+
try:
|
|
340
|
+
picks = [int(x.strip()) for x in raw.split(",") if x.strip()]
|
|
341
|
+
if all(1 <= p <= len(options) for p in picks) and picks:
|
|
342
|
+
return [p - 1 for p in picks]
|
|
343
|
+
except ValueError:
|
|
344
|
+
pass
|
|
345
|
+
print(f" {_red('✗')} Enter valid numbers between 1 and {len(options)}")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _fallback_single(prompt: str, options: list[str], default: int = 0) -> int:
|
|
349
|
+
print(f"\n{_cyan('▸')} {prompt}")
|
|
350
|
+
for i, opt in enumerate(options, 1):
|
|
351
|
+
marker = " (default)" if i - 1 == default else ""
|
|
352
|
+
print(f" {_bold(str(i))}. {opt}{marker}")
|
|
353
|
+
while True:
|
|
354
|
+
raw = _prompt("Enter number", str(default + 1))
|
|
355
|
+
try:
|
|
356
|
+
pick = int(raw.strip())
|
|
357
|
+
if 1 <= pick <= len(options):
|
|
358
|
+
return pick - 1
|
|
359
|
+
except ValueError:
|
|
360
|
+
pass
|
|
361
|
+
print(f" {_red('✗')} Enter a number between 1 and {len(options)}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# Text prompts
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _prompt(prompt: str, default: str = "") -> str:
|
|
370
|
+
"""Text prompt with optional default."""
|
|
371
|
+
suffix = f" [{default}]" if default else ""
|
|
372
|
+
# No color: readline renders raw SGR literally on dumb terminals.
|
|
373
|
+
try:
|
|
374
|
+
answer = input(f"▸ {prompt}{suffix}: ").strip()
|
|
375
|
+
except (EOFError, KeyboardInterrupt):
|
|
376
|
+
print()
|
|
377
|
+
raise KeyboardInterrupt
|
|
378
|
+
return answer or default
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _prompt_password(prompt: str) -> str:
|
|
382
|
+
"""Prompt for a password with validation and confirmation."""
|
|
383
|
+
import getpass
|
|
384
|
+
|
|
385
|
+
print(f"\n{_cyan('▸')} Password requirements:")
|
|
386
|
+
print(f" • 8 or more characters")
|
|
387
|
+
print(f" • At least one uppercase letter")
|
|
388
|
+
print(f" • At least one lowercase letter")
|
|
389
|
+
print(f" • At least one digit")
|
|
390
|
+
print(f" • At least one special character (!@#$%^&*...)")
|
|
391
|
+
print()
|
|
392
|
+
while True:
|
|
393
|
+
try:
|
|
394
|
+
pw = getpass.getpass(f"▸ {prompt}: ")
|
|
395
|
+
except (EOFError, KeyboardInterrupt):
|
|
396
|
+
print()
|
|
397
|
+
raise KeyboardInterrupt
|
|
398
|
+
from .env import validate_password
|
|
399
|
+
|
|
400
|
+
valid, msg = validate_password(pw)
|
|
401
|
+
if not valid:
|
|
402
|
+
print(f" {_red('✗')} {msg}")
|
|
403
|
+
continue
|
|
404
|
+
try:
|
|
405
|
+
pw2 = getpass.getpass(f"▸ Confirm password: ")
|
|
406
|
+
except (EOFError, KeyboardInterrupt):
|
|
407
|
+
print()
|
|
408
|
+
raise KeyboardInterrupt
|
|
409
|
+
if pw != pw2:
|
|
410
|
+
print(f" {_red('✗')} Passwords do not match")
|
|
411
|
+
continue
|
|
412
|
+
return pw
|