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.
@@ -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