sqlbuddy-nwi 0.3.1__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.
db_security.py ADDED
@@ -0,0 +1,146 @@
1
+ """Prevent passwords, keys, and tokens from leaking into logs or error output.
2
+
3
+ Usage (call once, early in the server startup):
4
+
5
+ import db_security
6
+ db_security.install()
7
+
8
+ After install():
9
+ - The root logger and all descendant loggers get a SensitiveValueFilter that
10
+ replaces known sensitive env-var values with `***` in log messages.
11
+ - sys.excepthook + threading.excepthook are overridden to sanitize traceback
12
+ text before it reaches stderr.
13
+ - Exception __str__ / __repr__ on errors that embed env-var values (e.g.
14
+ connection errors that include the full config string) is left alone; the
15
+ hook strips those values from the traceback output only.
16
+ """
17
+
18
+ import logging
19
+ import os
20
+ import sys
21
+ import threading
22
+
23
+
24
+ _SENSITIVE_VARS: set[str] = set()
25
+ _SENSITIVE_VALUES: list[str] = []
26
+
27
+
28
+ def _collect_sensitive() -> tuple[set[str], list[str]]:
29
+ sensitive_keys = set()
30
+ sensitive_vals: list[str] = []
31
+ for key, value in os.environ.items():
32
+ upper = key.upper()
33
+ if any(
34
+ tag in upper
35
+ for tag in ("_PASS", "_PASSWORD", "_KEY", "_TOKEN", "_SECRET", "_CRED")
36
+ ):
37
+ sensitive_keys.add(key)
38
+ if value:
39
+ sensitive_vals.append(value)
40
+ return sensitive_keys, sensitive_vals
41
+
42
+
43
+ class SensitiveValueFilter(logging.Filter):
44
+ def filter(self, record: logging.LogRecord) -> bool:
45
+ msg = record.getMessage()
46
+ if not _SENSITIVE_VALUES:
47
+ return True
48
+ for val in _SENSITIVE_VALUES:
49
+ if val and val in msg:
50
+ msg = msg.replace(val, "***")
51
+ record.msg = msg
52
+ record.args = None
53
+ return True
54
+
55
+
56
+ _original_excepthook = sys.excepthook
57
+ _original_threading_excepthook = threading.excepthook
58
+
59
+
60
+ def _sanitize_excepthook(exc_type, exc_value, exc_tb):
61
+ exc_value = _sanitize_exception(exc_value)
62
+ _original_excepthook(exc_type, exc_value, exc_tb)
63
+
64
+
65
+ def _sanitize_threading_excepthook(args):
66
+ if hasattr(args, "exc_value") and args.exc_value is not None:
67
+ args.exc_value = _sanitize_exception(args.exc_value)
68
+ _original_threading_excepthook(args)
69
+
70
+
71
+ def _sanitize_exception(exc_value):
72
+ if exc_value is None:
73
+ return exc_value
74
+ try:
75
+ msg = str(exc_value)
76
+ if not _SENSITIVE_VALUES:
77
+ return exc_value
78
+ for val in _SENSITIVE_VALUES:
79
+ if val and val in msg:
80
+ msg = msg.replace(val, "***")
81
+ exc_value.args = (msg,)
82
+ except Exception:
83
+ pass
84
+ return exc_value
85
+
86
+
87
+ def _load_env_file() -> str | None:
88
+ """Load .env from the configured path. Returns the path used, or None."""
89
+ env_path = os.environ.get("SQL_BUDDY_ENV_FILE")
90
+ if env_path:
91
+ env_path = os.path.expanduser(env_path)
92
+ _load_env(env_path)
93
+ return env_path
94
+ repo_root = os.path.dirname(os.path.abspath(__file__))
95
+ for name in (".env", ".env.local"):
96
+ candidate = os.path.join(repo_root, name)
97
+ if os.path.isfile(candidate):
98
+ _load_env(candidate)
99
+ return candidate
100
+ return None
101
+
102
+
103
+ def _load_env(path: str):
104
+ if not os.path.isfile(path):
105
+ return
106
+ with open(path, "r", encoding="utf-8") as f:
107
+ for line in f:
108
+ line = line.strip()
109
+ if not line or line.startswith("#"):
110
+ continue
111
+ if "=" not in line:
112
+ continue
113
+ key, _, value = line.partition("=")
114
+ key = key.strip()
115
+ value = value.strip().strip('"').strip("'")
116
+ if key and key not in os.environ:
117
+ os.environ[key] = value
118
+
119
+
120
+ def install():
121
+ """Activate all protections: .env loading, log filters, excepthooks."""
122
+ _load_env_file()
123
+ keys, vals = _collect_sensitive()
124
+ _SENSITIVE_VARS.update(keys)
125
+ _SENSITIVE_VALUES.extend(v for v in vals if v and v not in _SENSITIVE_VALUES)
126
+
127
+ root = logging.getLogger()
128
+ root.addFilter(SensitiveValueFilter())
129
+
130
+ sys.excepthook = _sanitize_excepthook
131
+ threading.excepthook = _sanitize_threading_excepthook
132
+
133
+
134
+ def sanitize_text(text: str) -> str:
135
+ """Replace all known sensitive values with `***` in arbitrary text.
136
+
137
+ Call this before persisting SQL queries to history, returning data in MCP
138
+ responses, or writing to any durable log. Idempotent — if values are already
139
+ masked, no harm.
140
+ """
141
+ if not text:
142
+ return text
143
+ for val in _SENSITIVE_VALUES:
144
+ if val and val in text:
145
+ text = text.replace(val, "***")
146
+ return text
init.py ADDED
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env python3
2
+ """sql-buddy interactive setup wizard.
3
+
4
+ Asks how you reach your databases and writes `connections/config/db.ini` in
5
+ the right shape:
6
+
7
+ - Auto : sql-buddy opens the SSH tunnel itself. You give a bastion + key
8
+ file + per-DB remote mapping. Connections get `tunnel = ssh:*`.
9
+ - External : you already run a tunnel/connection elsewhere (e.g. an `ssh -N`
10
+ terminal on Windows, or a DB exposed locally). sql-buddy just
11
+ connects to a local host:port. No `tunnel` keys, so the
12
+ self-managed tunnel code stays dormant.
13
+
14
+ Run: python init.py (or, after `uv pip install -e .`: sql-buddy-init)
15
+ """
16
+
17
+ import configparser
18
+ import json
19
+ import os
20
+ import shutil
21
+ import sys
22
+ from getpass import getpass
23
+
24
+ try:
25
+ from connections.config_util import config_write_path as _config_write_path
26
+ except ImportError:
27
+ _config_write_path = None
28
+
29
+ # Resolved via the shared helper (env > user dir > checkout-relative; see
30
+ # connections/config_util.py). Falls back to the checkout-relative path if the
31
+ # package isn't importable (e.g. a bare `python init.py` against a vendored tree).
32
+ CONFIG_FILE = (_config_write_path() if _config_write_path
33
+ else os.path.join(os.path.dirname(os.path.abspath(__file__)),
34
+ 'connections', 'config', 'db.ini'))
35
+
36
+
37
+ # --- WSL detection (lightweight, no heavy imports) ------------------------
38
+
39
+ def _is_wsl() -> bool:
40
+ try:
41
+ with open('/proc/version') as f:
42
+ return 'microsoft' in f.read().lower()
43
+ except OSError:
44
+ return False
45
+
46
+
47
+ # --- prompt helpers -------------------------------------------------------
48
+
49
+ def _ask(prompt: str, default: str = None, optional: bool = False) -> str:
50
+ suffix = f" [{default}]" if default is not None else ""
51
+ while True:
52
+ val = input(f"{prompt}{suffix}: ").strip()
53
+ if val:
54
+ return val
55
+ if default is not None:
56
+ return default
57
+ if optional:
58
+ return ""
59
+ print(" (required - please answer)")
60
+
61
+
62
+ def _ask_int(prompt: str, default: int = None) -> int:
63
+ while True:
64
+ raw = _ask(prompt, str(default) if default is not None else None)
65
+ try:
66
+ return int(raw)
67
+ except ValueError:
68
+ print(" (must be an integer)")
69
+
70
+
71
+ def _ask_password(prompt: str) -> str:
72
+ try:
73
+ return getpass(f"{prompt}: ")
74
+ except EOFError:
75
+ return ""
76
+
77
+
78
+ def _yes_no(prompt: str, default: bool = False) -> bool:
79
+ raw = input(f"{prompt} [{'y' if default else 'n'}]: ").strip().lower()
80
+ if not raw:
81
+ return default
82
+ return raw.startswith('y')
83
+
84
+
85
+ # --- mode selection -------------------------------------------------------
86
+
87
+ def _choose_mode() -> int:
88
+ print(
89
+ "\nHow do you reach your databases?\n"
90
+ " 1) Auto - sql-buddy opens the SSH tunnel itself\n"
91
+ " (you provide a bastion/jump-host + a private key file)\n"
92
+ " 2) External - you already have a tunnel/connection running\n"
93
+ " (e.g. an `ssh -N` terminal on Windows, or a local DB)\n"
94
+ " sql-buddy just connects to a local host:port"
95
+ )
96
+ while True:
97
+ c = input("\nChoose [1/2]: ").strip()
98
+ if c in ('1', '2'):
99
+ return int(c)
100
+ print(" (enter 1 or 2)")
101
+
102
+
103
+ # --- bastion (auto mode) --------------------------------------------------
104
+
105
+ def _ask_bastion(cfg: configparser.RawConfigParser) -> str:
106
+ print("\n--- Bastion / jump-host ---")
107
+ if sys.platform == "win32" and not shutil.which("ssh"):
108
+ print(
109
+ " note: `ssh` not on PATH. Auto mode needs OpenSSH Client\n"
110
+ " (Windows Settings → Apps → Optional features → OpenSSH Client).\n"
111
+ " Or pick External mode and run `ssh -N` yourself."
112
+ )
113
+ name = _ask("bastion name", "prod")
114
+ section = f"ssh:{name}"
115
+ cfg.add_section(section)
116
+ cfg.set(section, "host", _ask("SSH host"))
117
+ cfg.set(section, "port", str(_ask_int("SSH port", 22)))
118
+ cfg.set(section, "user", _ask("SSH user"))
119
+ key_default = os.path.join("~", ".ssh", "id_ed25519")
120
+ key_raw = _ask(f"Private key path (e.g. {key_default})")
121
+ # Expand ~ and normalize slashes so Windows paths like
122
+ # C:\Users\…\.ssh\id_ed25519 and ~/ .ssh/… both work at runtime.
123
+ key = os.path.normpath(os.path.expanduser(key_raw.strip().strip('"')))
124
+ cfg.set(section, "key", key)
125
+ cfg.set(section, "server_alive_interval", str(_ask_int("ServerAliveInterval (seconds)", 30)))
126
+ cfg.set(section, "server_alive_count_max", str(_ask_int("ServerAliveCountMax", 3)))
127
+ return name
128
+
129
+
130
+ def _default_remote_port(ctype: str) -> int:
131
+ return 9000 if ctype == 'clickhouse' else 3306
132
+
133
+
134
+ def _add_connection_auto(cfg: configparser.RawConfigParser, block: str):
135
+ print("\n--- Database connection ---")
136
+ name = _ask("Connection name (e.g. ck, mysql)")
137
+ cfg.add_section(name)
138
+ ctype = _ask("Type [clickhouse/mysql]", "clickhouse")
139
+ cfg.set(name, "type", ctype)
140
+ cfg.set(name, "tunnel", f"ssh:{block}")
141
+ cfg.set(name, "remote_host", _ask("Remote DB host (as seen FROM the bastion)"))
142
+ cfg.set(name, "remote_port", str(_ask_int("Remote DB port", _default_remote_port(ctype))))
143
+ cfg.set(name, "local_port", str(_ask_int("Local port (on THIS machine, must be free)")))
144
+ cfg.set(name, "user", _ask("DB user (read-only recommended)"))
145
+ cfg.set(name, "password", _ask_password("DB password"))
146
+ db = _ask("Default database", optional=True)
147
+ if db:
148
+ cfg.set(name, "database", db)
149
+
150
+
151
+ # --- external mode: edit db.ini by hand -----------------------------------
152
+ # External config is simple (host/port/user/password) and often already exists,
153
+ # so we don't prompt for it - we point the user at db.ini to edit directly.
154
+ # Pasting passwords with special chars into a prompt is error-prone; a file edit
155
+ # is safer and lets the user reuse values they already have.
156
+
157
+ _EXTERNAL_TEMPLATE = """\
158
+ # db.ini - EXTERNAL tunnel mode. NEVER commit this file (gitignored).
159
+ #
160
+ # You run the SSH tunnel/connection elsewhere (e.g. `ssh -N` on Windows);
161
+ # sql-buddy connects to host:port below directly. No `tunnel` keys here.
162
+ # Add one [section] per database. See db.ini.example for the full format.
163
+
164
+ [ck]
165
+ type = clickhouse
166
+ host = localhost # local end of your external tunnel
167
+ port = 9000 # LOCAL tunnel port (not the remote DB port)
168
+ user = your_readonly_user
169
+ password = your_password
170
+ database = default
171
+
172
+ # Example MySQL connection - copy & rename to add more:
173
+ # [mysql]
174
+ # type = mysql
175
+ # host = localhost
176
+ # port = 3306
177
+ # user = your_readonly_user
178
+ # password = your_password
179
+ # database = myapp
180
+ """
181
+
182
+ _EXTERNAL_HELP = (
183
+ "Edit db.ini: one [section] per database, with host/port/user/password\n"
184
+ " (and `database` for ClickHouse).\n"
185
+ " - host: local end of your external tunnel (usually localhost)\n"
186
+ " - port: the LOCAL port your tunnel forwards (not the remote DB port)\n"
187
+ f"See {os.path.basename(CONFIG_FILE)}.example for the full format."
188
+ )
189
+
190
+ _WSL_HINT = (
191
+ "WSL detected: sql-buddy auto-rewrites `host` to the Windows host IP, so your\n"
192
+ "Windows-side tunnels must bind 0.0.0.0 (your `-L 0.0.0.0:...` already does)."
193
+ )
194
+
195
+ _VERIFY_HINT = (
196
+ "Next, verify with:\n"
197
+ " python -c \"import db_query; print(db_query.list_connections())\"\n"
198
+ " python -c \"import db_query; print(db_query.execute_limit_query('SELECT 1','<conn>'))\""
199
+ )
200
+
201
+
202
+ def _write_external_template():
203
+ os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
204
+ with open(CONFIG_FILE, 'w') as f:
205
+ f.write(_EXTERNAL_TEMPLATE)
206
+
207
+
208
+ # --- write ----------------------------------------------------------------
209
+
210
+ def _backup_existing() -> str | None:
211
+ """Copy current db.ini to a 0600 backup next to it (gitignored as db.ini.bak*)."""
212
+ if not os.path.exists(CONFIG_FILE):
213
+ return None
214
+ n = 1
215
+ while os.path.exists(f"{CONFIG_FILE}.bak{n}"):
216
+ n += 1
217
+ bak = f"{CONFIG_FILE}.bak{n}"
218
+ shutil.copy2(CONFIG_FILE, bak)
219
+ try:
220
+ os.chmod(bak, 0o600)
221
+ except OSError:
222
+ pass
223
+ return bak
224
+
225
+
226
+ def _setup_auto():
227
+ cfg = configparser.RawConfigParser()
228
+ block = _ask_bastion(cfg)
229
+ _add_connection_auto(cfg, block)
230
+ while _yes_no("\nAdd another connection?", default=False):
231
+ _add_connection_auto(cfg, block)
232
+ _write_cfg(cfg, "auto - self-managed SSH tunnels")
233
+
234
+
235
+ def _write_cfg(cfg, mode_label: str):
236
+ """Back up any existing db.ini, then write `cfg` to CONFIG_FILE at mode 0600.
237
+
238
+ Shared by the interactive and non-interactive paths so they can never
239
+ drift in how they persist + verify. Mode 0600 so plaintext DB passwords
240
+ are never world-readable under a permissive umask.
241
+ """
242
+ import stat
243
+ import tempfile
244
+ bak = _backup_existing()
245
+ d = os.path.dirname(CONFIG_FILE)
246
+ os.makedirs(d, exist_ok=True)
247
+ # Preserve existing mode if already strict; otherwise force 0600.
248
+ mode = 0o600
249
+ if os.path.exists(CONFIG_FILE):
250
+ mode = stat.S_IMODE(os.stat(CONFIG_FILE).st_mode) | 0o600
251
+ # never widen beyond owner-only for a credential file
252
+ mode = mode & 0o700
253
+ if mode == 0:
254
+ mode = 0o600
255
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".db.ini.")
256
+ try:
257
+ try:
258
+ os.chmod(tmp, 0o600)
259
+ except OSError:
260
+ pass # Windows: chmod is best-effort
261
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
262
+ cfg.write(f)
263
+ os.replace(tmp, CONFIG_FILE)
264
+ try:
265
+ os.chmod(CONFIG_FILE, 0o600)
266
+ except OSError:
267
+ pass
268
+ finally:
269
+ if os.path.exists(tmp):
270
+ try:
271
+ os.remove(tmp)
272
+ except OSError:
273
+ pass
274
+ print(f"\nWrote {CONFIG_FILE} (mode: {mode_label}, perms 0600)")
275
+ if bak:
276
+ print(f"Previous config backed up to {bak}")
277
+ print("\n" + _VERIFY_HINT)
278
+
279
+
280
+ def _setup_external():
281
+ # Don't prompt for connection details - external config is simple and often
282
+ # already exists. Point the user at db.ini to edit by hand instead.
283
+ if os.path.exists(CONFIG_FILE):
284
+ print(f"\nYou already have a db.ini - edit it directly:\n {CONFIG_FILE}")
285
+ else:
286
+ _write_external_template()
287
+ print(f"\nWrote a starter db.ini - edit it to fill in your connections:\n {CONFIG_FILE}")
288
+ print("\n" + _EXTERNAL_HELP)
289
+ if _is_wsl():
290
+ print("\n" + _WSL_HINT)
291
+ print("\n" + _VERIFY_HINT)
292
+
293
+
294
+ def main(argv: list[str] | None = None):
295
+ import argparse
296
+ parser = argparse.ArgumentParser(
297
+ prog="sql-buddy-init",
298
+ description="sql-buddy setup wizard — writes connections/config/db.ini",
299
+ )
300
+ parser.add_argument(
301
+ "--non-interactive", action="store_true",
302
+ help="write db.ini from a JSON spec without prompting (for agents / CI)",
303
+ )
304
+ # No --json '<inline>': argv is visible in process listings and shell/agent
305
+ # history, so credentials must never travel that way. Use stdin or a file.
306
+ parser.add_argument("--json-file", dest="json_file",
307
+ help="path to a setup-spec JSON file (use '-' for stdin)")
308
+ parser.add_argument("--json-stdin", action="store_true",
309
+ help="read the setup spec from stdin (preferred for secrets)")
310
+ args = parser.parse_args(argv)
311
+
312
+ if args.non_interactive:
313
+ _setup_non_interactive(_load_spec(args.json_file, args.json_stdin))
314
+ return 0
315
+
316
+ print(
317
+ "=== sql-buddy setup ===\n"
318
+ f"Configures {CONFIG_FILE}"
319
+ )
320
+ mode = _choose_mode()
321
+ if mode == 1:
322
+ _setup_auto()
323
+ else:
324
+ _setup_external()
325
+ return 0
326
+
327
+
328
+ # --- non-interactive spec -> db.ini ---------------------------------------
329
+ # Driven by a JSON object so an agent (or CI) can produce db.ini without the
330
+ # interactive prompts. Secrets stay local: they go straight from the spec into
331
+ # db.ini, never echoed.
332
+ #
333
+ # Schema (auto mode):
334
+ # {"mode":"auto",
335
+ # "ssh": {"name":"prod","host":"...","port":22,"user":"...","key":"~/.ssh/...",
336
+ # "server_alive_interval":30,"server_alive_count_max":3},
337
+ # "connections":[{"name":"ck","type":"clickhouse",
338
+ # "remote_host":"10.0.0.21","remote_port":9000,"local_port":10021,
339
+ # "user":"ro","password":"...","database":"default"}]}
340
+ #
341
+ # Schema (external mode):
342
+ # {"mode":"external",
343
+ # "connections":[{"name":"ck","type":"clickhouse","host":"localhost","port":9000,
344
+ # "user":"ro","password":"...","database":"default"}]}
345
+
346
+ def _guess_type(name: str) -> str:
347
+ """Mirror the section-name inference used at query time (ck*->clickhouse, else mysql)."""
348
+ low = name.lower()
349
+ if low.startswith("ck"):
350
+ return "clickhouse"
351
+ if low.startswith(("db", "mysql")):
352
+ return "mysql"
353
+ return "mysql"
354
+
355
+
356
+ _VALID_TYPES = frozenset({"clickhouse", "mysql", "mariadb"})
357
+
358
+
359
+ def _require(d: dict, key: str, where: str, *, allow_empty: bool = False):
360
+ """Require a field. `allow_empty=True` permits '' (passwordless DB users)."""
361
+ if key not in d or d[key] is None:
362
+ raise SystemExit(f"{where}: missing required field '{key}'")
363
+ if d[key] == "" and not allow_empty:
364
+ raise SystemExit(f"{where}: missing required field '{key}'")
365
+ return d[key]
366
+
367
+
368
+ def _require_port(d: dict, key: str, where: str, default=None) -> int:
369
+ """Require an integer port in 1..65535 (matches runtime int(port))."""
370
+ if key not in d or d[key] in (None, ""):
371
+ if default is not None:
372
+ return int(default)
373
+ raise SystemExit(f"{where}: missing required field '{key}'")
374
+ try:
375
+ p = int(d[key])
376
+ except (TypeError, ValueError):
377
+ raise SystemExit(f"{where}: '{key}' must be an integer port, got {d[key]!r}")
378
+ if not (1 <= p <= 65535):
379
+ raise SystemExit(f"{where}: '{key}' must be 1..65535, got {p}")
380
+ return p
381
+
382
+
383
+ def _resolve_type(conn: dict, name: str) -> str:
384
+ raw = conn.get("type")
385
+ if raw in (None, ""):
386
+ return _guess_type(name)
387
+ ctype = str(raw).strip().lower()
388
+ if ctype not in _VALID_TYPES:
389
+ raise SystemExit(
390
+ f"connection [{name}]: unsupported type '{ctype}' "
391
+ f"(want clickhouse|mysql|mariadb)"
392
+ )
393
+ return ctype
394
+
395
+
396
+ def _add_conn_section(cfg, conn: dict, *, auto_block: str | None):
397
+ name = _require(conn, "name", "connection")
398
+ # Runtime skips every [ssh:*] section as a bastion block (case-insensitive).
399
+ if str(name).lower().startswith("ssh:"):
400
+ raise SystemExit(
401
+ f"connection name '{name}' is reserved (ssh:* sections are bastions, "
402
+ "not Connections)"
403
+ )
404
+ if cfg.has_section(name) or cfg.has_section(f"ssh:{name}"):
405
+ raise SystemExit(f"connection name '{name}' is duplicated or reserved")
406
+ cfg.add_section(name)
407
+ cfg.set(name, "type", _resolve_type(conn, name))
408
+ if auto_block is not None:
409
+ cfg.set(name, "tunnel", f"ssh:{auto_block}")
410
+ cfg.set(name, "remote_host",
411
+ str(_require(conn, "remote_host", f"connection [{name}]")))
412
+ cfg.set(name, "remote_port",
413
+ str(_require_port(conn, "remote_port", f"connection [{name}]")))
414
+ cfg.set(name, "local_port",
415
+ str(_require_port(conn, "local_port", f"connection [{name}]")))
416
+ cfg.set(name, "user", str(_require(conn, "user", f"connection [{name}]")))
417
+ # Empty password is valid (passwordless local MySQL / some ClickHouse setups).
418
+ cfg.set(name, "password",
419
+ str(_require(conn, "password", f"connection [{name}]",
420
+ allow_empty=True)))
421
+ else:
422
+ cfg.set(name, "host", str(_require(conn, "host", f"connection [{name}]")))
423
+ cfg.set(name, "port",
424
+ str(_require_port(conn, "port", f"connection [{name}]")))
425
+ cfg.set(name, "user", str(_require(conn, "user", f"connection [{name}]")))
426
+ cfg.set(name, "password",
427
+ str(_require(conn, "password", f"connection [{name}]",
428
+ allow_empty=True)))
429
+ if conn.get("database"):
430
+ cfg.set(name, "database", str(conn["database"]))
431
+
432
+
433
+ def _cfg_from_auto_spec(spec: dict) -> tuple[configparser.RawConfigParser, str]:
434
+ ssh = spec.get("ssh") or {}
435
+ block = ssh.get("name") or "prod"
436
+ section = f"ssh:{block}"
437
+ cfg = configparser.RawConfigParser()
438
+ cfg.add_section(section)
439
+ cfg.set(section, "host", str(_require(ssh, "host", "ssh")))
440
+ cfg.set(section, "port", str(_require_port(ssh, "port", "ssh", default=22)))
441
+ cfg.set(section, "user", str(_require(ssh, "user", "ssh")))
442
+ key = str(_require(ssh, "key", "ssh")).strip().strip('"')
443
+ cfg.set(section, "key", os.path.normpath(os.path.expanduser(key)))
444
+ # keepalive is seconds (not a TCP port) — any non-negative / positive int.
445
+ if "server_alive_interval" in ssh:
446
+ try:
447
+ sai = int(ssh["server_alive_interval"])
448
+ if sai < 0:
449
+ raise ValueError
450
+ except (TypeError, ValueError):
451
+ raise SystemExit("ssh: server_alive_interval must be a non-negative integer")
452
+ cfg.set(section, "server_alive_interval", str(sai))
453
+ else:
454
+ cfg.set(section, "server_alive_interval", "30")
455
+ if "server_alive_count_max" in ssh:
456
+ try:
457
+ sac = int(ssh["server_alive_count_max"])
458
+ if sac < 1:
459
+ raise ValueError
460
+ except (TypeError, ValueError):
461
+ raise SystemExit("ssh: server_alive_count_max must be a positive integer")
462
+ cfg.set(section, "server_alive_count_max", str(sac))
463
+ else:
464
+ cfg.set(section, "server_alive_count_max", "3")
465
+ conns = spec.get("connections") or []
466
+ if not conns:
467
+ raise SystemExit("at least one connection is required")
468
+ for c in conns:
469
+ _add_conn_section(cfg, c, auto_block=block)
470
+ return cfg, "auto - self-managed SSH tunnels"
471
+
472
+
473
+ def _cfg_from_external_spec(spec: dict) -> tuple[configparser.RawConfigParser, str]:
474
+ cfg = configparser.RawConfigParser()
475
+ conns = spec.get("connections") or []
476
+ if not conns:
477
+ raise SystemExit("at least one connection is required")
478
+ for c in conns:
479
+ _add_conn_section(cfg, c, auto_block=None)
480
+ return cfg, "external"
481
+
482
+
483
+ def _load_spec(json_file: str | None, json_stdin: bool) -> dict:
484
+ """Resolve the setup spec to a JSON object.
485
+
486
+ Secret-safe sources only (no inline argv JSON):
487
+ stdin (--json-stdin, or --json-file -) > protected file (--json-file)
488
+ > $SQL_BUDDY_SETUP_JSON env.
489
+ """
490
+ raw: str | None = None
491
+ if json_stdin or json_file == "-":
492
+ raw = sys.stdin.read()
493
+ elif json_file:
494
+ with open(os.path.expanduser(json_file)) as f:
495
+ raw = f.read()
496
+ elif os.environ.get("SQL_BUDDY_SETUP_JSON") is not None:
497
+ raw = os.environ.get("SQL_BUDDY_SETUP_JSON")
498
+ if raw is None:
499
+ raise SystemExit(
500
+ "--non-interactive needs one of: --json-stdin / --json-file <path|-> "
501
+ "/ $SQL_BUDDY_SETUP_JSON (credentials must not travel via argv)"
502
+ )
503
+ try:
504
+ spec = json.loads(raw)
505
+ except json.JSONDecodeError as e:
506
+ raise SystemExit(f"invalid setup JSON: {e}")
507
+ if not isinstance(spec, dict):
508
+ raise SystemExit("setup spec must be a JSON object")
509
+ return spec
510
+
511
+
512
+ def _setup_non_interactive(spec: dict):
513
+ mode = (spec.get("mode") or "auto").lower()
514
+ if mode == "auto":
515
+ cfg, label = _cfg_from_auto_spec(spec)
516
+ elif mode == "external":
517
+ cfg, label = _cfg_from_external_spec(spec)
518
+ else:
519
+ raise SystemExit(f"unknown mode '{mode}' (want 'auto' or 'external')")
520
+ _write_cfg(cfg, label)
521
+
522
+
523
+ if __name__ == "__main__":
524
+ try:
525
+ main()
526
+ except KeyboardInterrupt:
527
+ print("\nAborted - db.ini unchanged.")
528
+ sys.exit(1)