ssh-tunnel-cli 0.1.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.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: ssh-tunnel-cli
3
+ Version: 0.1.1
4
+ Summary: Guided local SSH tunnels using your existing OpenSSH configuration
5
+ Project-URL: Repository, https://github.com/maneeshv/ssh-tunnel-cli
6
+ Project-URL: Issues, https://github.com/maneeshv/ssh-tunnel-cli/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: MacOS :: MacOS X
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Internet
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: prompt-toolkit==3.0.53
16
+ Requires-Dist: questionary==2.1.1
17
+ Description-Content-Type: text/markdown
18
+
19
+ # tunnel
20
+
21
+ Create and manage local SSH tunnels without remembering forwarding syntax. Pick an alias from your SSH config, answer the prompts, review the exact commands, and choose **Start tunnel**.
22
+
23
+ Python + Questionary, using your Mac's existing OpenSSH client for connections and authentication. No remote agent or installed manager service.
24
+
25
+ ## Install
26
+
27
+ Requires macOS, Python 3.11 or later, [uv](https://docs.astral.sh/uv/), and `/usr/bin/ssh`.
28
+
29
+ ```sh
30
+ uv tool install ssh-tunnel-cli
31
+ tunnel
32
+ ```
33
+
34
+ Alternatively, use `pipx install ssh-tunnel-cli`, or `python -m pip install ssh-tunnel-cli` inside a virtual environment. The Python package exposes the same `tunnel` command.
35
+
36
+ If your shell cannot find `tunnel`, run `uv tool update-shell` and restart the shell.
37
+
38
+ To run or develop from a checkout:
39
+
40
+ ```sh
41
+ git clone https://github.com/maneeshv/ssh-tunnel-cli.git
42
+ cd ssh-tunnel-cli
43
+ uv sync --locked
44
+ uv run tunnel
45
+ ```
46
+
47
+ From that checkout, `uv tool install .` installs the command for everyday use.
48
+
49
+ Your config must already contain a literal alias, for example:
50
+
51
+ ```sshconfig
52
+ Host other-mac
53
+ HostName your-other-machine
54
+ User your-username
55
+ ```
56
+
57
+ Configure and verify SSH access yourself first. `tunnel` does not edit SSH config, set up Tailscale, or save credentials. Add hosts to your config manually and reload the picker.
58
+
59
+ ## Use
60
+
61
+ - **New tunnel:** machine → remote app port → local port → destination → mode → review.
62
+ - **Background:** returns to the shell; SSH keeps running after that terminal closes.
63
+ - **Foreground:** remains attached; Ctrl+C stops this tunnel.
64
+ - **Manage tunnels:** inspect commands and diagnostics, stop, or review a restart.
65
+ - **Recent tunnels:** reuse successful settings, with a fresh review before connecting.
66
+ - **Esc:** back, retaining answers. **Ctrl+C:** cancel. Arrow keys select; type to filter the host picker.
67
+
68
+ ```sh
69
+ tunnel list
70
+ tunnel stop
71
+ tunnel restart
72
+ tunnel --help
73
+ ```
74
+
75
+ The app port is different from the SSH server port. SSH's server port comes from your configuration. The destination `127.0.0.1` means loopback **on the selected remote machine**; the local listener is always `127.0.0.1` **on this Mac**. No public/LAN bind option is provided.
76
+
77
+ **Running means SSH is active, not that the remote app is healthy.** Browser opening is optional and only appropriate for web apps. Tailscale is optional existing connectivity, not something this utility configures or guarantees.
78
+
79
+ ## Connection ownership
80
+
81
+ Every attempt has its own private control socket. The reviewed launch has two commands: first start an isolated SSH connection with inherited forwards cleared, then add the one selected local forward through that socket. This avoids accidentally launching forwards defined elsewhere in SSH configuration. SSH continues to resolve keys, users, jump hosts, and authentication settings. LocalCommand, agent/X11 forwarding, tunnel devices, and persistent/shared control settings are overridden for this utility.
82
+
83
+ Only tool-owned connections appear in the inventory. Stop never searches by port or kills an arbitrary PID. Closing the menu does not stop a background tunnel. Network failures require a manual restart; no auto-start or reconnect service is installed.
84
+
85
+ Native SSH authentication and host-key prompts appear only after Start. User SSH config is trusted: connection configuration such as ProxyCommand or Match exec can execute its configured commands at connection time. Host discovery itself does not execute those directives.
86
+
87
+ ## State and limitations
88
+
89
+ Records, a lock file, SSH control sockets, and diagnostics live in `~/.tunnel/`, with owner-only permissions. Override the location with `--state-dir`; use `--config` for a separate SSH config. A short state path is required by Unix control-socket limits. An unsafe or corrupt state store is reported and preserved, not silently repaired or discarded.
90
+
91
+ Host discovery supports literal ASCII aliases and ordinary nested/globbed Includes. Conditional Includes and paths containing SSH tokens or environment expansions are skipped with a warning that the list may be incomplete. Wildcard/negated Host patterns are not selectable machines. Relative user Includes resolve under `~/.ssh`, including when `--config` points elsewhere, matching OpenSSH.
92
+
93
+ Diagnostics contain SSH errors, not remote application logs or typed authentication input. Each log is trimmed to its last 64 KiB when viewed. Without a background manager, logs can grow between views. Old records and diagnostics remain in the private state directory; no automatic history deletion is performed.
94
+
95
+ Foreground/background lifecycle is macOS-first. Other operating systems and every OpenSSH configuration combination are not promised.
96
+
97
+ ## Verify
98
+
99
+ Unit and prompt tests (no real hosts):
100
+
101
+ ```sh
102
+ uv run python -m unittest discover -s tests -v
103
+ ```
104
+
105
+ Opt-in integration tests start an isolated loopback `sshd`, generate disposable keys, and exercise actual forwarding. They do not read your SSH keys or configuration, change system SSH settings, or contact remote machines. They require permission to bind/connect loopback ports, `/usr/sbin/sshd`, `/usr/bin/ssh-keygen`, and `trash` for fixture cleanup.
106
+
107
+ ```sh
108
+ TUNNEL_SSH_INTEGRATION=1 uv run python -m unittest tests.test_ssh_integration -v
109
+ ```
110
+
111
+ See [the specification](https://github.com/maneeshv/ssh-tunnel-cli/blob/main/SPEC.md) for requirements, decisions, and the [original Warp mockup](https://github.com/maneeshv/ssh-tunnel-cli/blob/main/design/tunnel-mockup.png). The mockup is a design reference, not a screenshot of the implemented terminal UI.
112
+
113
+ Verified on 2026-11-04 with Python 3.11.11 and macOS OpenSSH 10.3p1: all 73 tests passed, including 12 real-SSH integration tests. The wheel and source archive build successfully, and the wheel's CLI runs independently of the source checkout. Menus were inspected in a 96-column terminal with color disabled. Manual Warp rendering and password/passphrase authentication were not checked; generated-key authentication and native host-key confirmation were exercised through a PTY.
114
+
115
+ ## TypeScript comparison
116
+
117
+ A separate [TypeScript alternative](https://github.com/maneeshv/ssh-tunnel-cli/tree/main/typescript-version) is available for comparison. Run `npm ci && npm run build && npm start` from `typescript-version/`. It uses the `tunnel-ts` command and its own state directory; npm publication is pending evaluation.
118
+
119
+ ## Contributing
120
+
121
+ Small fixes and improvements are welcome. Open an issue for substantial behavior changes before implementing them. Use absolute imports, keep dependencies minimal, and run the checks above before submitting a pull request. Include a regression test for behavior changes. CI runs unit/prompt tests and package smoke checks on macOS; real-SSH tests remain opt-in.
122
+
123
+ When reporting a bug, include the macOS, Python, and OpenSSH versions and sanitized reproduction steps. Remove hostnames, usernames, addresses, credentials, and private configuration from pasted diagnostics.
124
+
125
+ ## License
126
+
127
+ [MIT](https://github.com/maneeshv/ssh-tunnel-cli/blob/main/LICENSE). The mockup is an AI-generated design reference; the project is not affiliated with Warp.
@@ -0,0 +1,10 @@
1
+ tunnel_cli/__init__.py,sha256=NBntThTKm44yTq_eM_WoRJPUb4INAxZPsOnz0i6dSEc,60
2
+ tunnel_cli/cli.py,sha256=IrqWezGYlRyh7hQKkDvLSJw5ole4j7wk0c_DYH9y2o4,12953
3
+ tunnel_cli/config.py,sha256=Ie9xf47vkqB6VWhBpTXxnqsGe2IJ5JJuUkZWyP2xpWA,4763
4
+ tunnel_cli/core.py,sha256=jCjfsYkaurVLD4tlBL0R-kY9TEXGRIdyZzaIBa3n8qo,19003
5
+ tunnel_cli/prompts.py,sha256=py_hWnyFTK5YVOojHAov-YFccn4wtsb0TeYjsklw2H0,4443
6
+ ssh_tunnel_cli-0.1.1.dist-info/METADATA,sha256=SN7RTqacPQdrEep-t_cPH0xNALGzHzOkPLkQiIupRJ4,7817
7
+ ssh_tunnel_cli-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ ssh_tunnel_cli-0.1.1.dist-info/entry_points.txt,sha256=9MTJNX4HXvKedLmEekeLC3-QOv2pLiX7N6pIeDSQv0s,47
9
+ ssh_tunnel_cli-0.1.1.dist-info/licenses/LICENSE,sha256=UeK6aVwkxvwo_r9FUsupT345hKjgzrtP3DXbBQSAB0g,1064
10
+ ssh_tunnel_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tunnel = tunnel_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maneesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tunnel_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Interactive local forwards managed by system OpenSSH."""
tunnel_cli/cli.py ADDED
@@ -0,0 +1,263 @@
1
+ """Guided setup and management, with no connection before a reviewed Start."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ import shlex
6
+ import shutil
7
+ import sys
8
+ import textwrap
9
+ import webbrowser
10
+
11
+ from questionary import Choice
12
+
13
+ from tunnel_cli.config import discover_hosts
14
+ from tunnel_cli.core import Manager, Settings, TunnelError, available_port, validate_destination, validate_port
15
+ from tunnel_cli.prompts import Back, choose, say, text
16
+
17
+
18
+ def label(record: dict) -> str:
19
+ s = record["settings"]
20
+ return f"{s['alias']} 127.0.0.1:{s['local_port']} → {s['destination']}:{s['remote_port']} {record['status']}"
21
+
22
+
23
+ def show_commands(record: dict):
24
+ say("\nExact commands — private SSH connection, then the selected forward:")
25
+ width = max(40, shutil.get_terminal_size().columns - 4)
26
+ for index, command in enumerate(record["commands"], 1):
27
+ say(textwrap.fill(shlex.join(command), width=width,
28
+ initial_indent=f" {index}. ", subsequent_indent=" ",
29
+ break_long_words=False, break_on_hyphens=False))
30
+
31
+
32
+ def show_details(record: dict):
33
+ say(f"\n{label(record)}\nID: {record['id']}\nMode: {record['settings']['mode']}\nCreated: {record['created_at']}")
34
+ if record.get("last_error"):
35
+ say(f"Last diagnostic: {record['last_error']}")
36
+ say("Running describes the SSH tunnel; application health is not checked.")
37
+
38
+
39
+ def _host(manager: Manager, default: str | None):
40
+ while True:
41
+ discovery = discover_hosts(manager.config_path)
42
+ for warning in discovery.warnings:
43
+ say(f"Warning: {warning}")
44
+ if discovery.aliases:
45
+ return choose("Choose a machine", discovery.aliases,
46
+ default=default if default in discovery.aliases else None, search=True)
47
+ say("No literal SSH aliases found. Add a Host alias to your SSH config manually, then reload.")
48
+ if choose("Next", ["Reload hosts", "Back"]) == "Back":
49
+ raise Back
50
+
51
+
52
+ def same_route(first: dict, second: dict) -> bool:
53
+ return all(first[key] == second[key] for key in ("alias", "local_port", "remote_port", "destination"))
54
+
55
+
56
+ def _started(record: dict):
57
+ settings = record["settings"]
58
+ say(f"\nTunnel running in {settings['mode']}: 127.0.0.1:{settings['local_port']}\nID: {record['id']}")
59
+ say("Tunnel active; application health not checked.")
60
+ if settings["mode"] == "foreground":
61
+ say("Ctrl+C stops this tunnel.")
62
+
63
+
64
+ def run_wizard(manager: Manager, initial: dict | None = None, restart: dict | None = None) -> bool:
65
+ answers = dict(initial or {})
66
+ step = 5 if initial else 0
67
+ editing = False
68
+ fields = ["Machine", "Remote port", "Local port", "Destination", "Run mode"]
69
+ while True:
70
+ try:
71
+ if step == 0:
72
+ answers["alias"] = _host(manager, answers.get("alias"))
73
+ elif step == 1:
74
+ say("Remote application port — the SSH server port comes from SSH config.")
75
+ answers["remote_port"] = validate_port(text("Remote port", str(answers.get("remote_port", "")),
76
+ validate=validate_port))
77
+ elif step == 2:
78
+ def local_port(value):
79
+ port = validate_port(value)
80
+ owned_port = restart and restart["status"] == "Running" and restart["settings"]["local_port"] == port
81
+ if not owned_port and not available_port(port):
82
+ raise ValueError(f"Port {port} is unavailable. Enter another local port.")
83
+ return port
84
+ answers["local_port"] = validate_port(text("Local port", str(answers.get("local_port", answers["remote_port"])),
85
+ validate=local_port))
86
+ elif step == 3:
87
+ say("127.0.0.1 means the selected remote machine. Advanced: another host/IP reachable from it.")
88
+ answers["destination"] = validate_destination(text("Destination", answers.get("destination", "127.0.0.1"),
89
+ validate=validate_destination))
90
+ elif step == 4:
91
+ say("Background returns to your shell. Foreground stays attached until Ctrl+C.")
92
+ answers["mode"] = choose("How should it run?", [Choice("Background", "background"), Choice("Foreground", "foreground")],
93
+ default=answers.get("mode", "background"))
94
+ else:
95
+ record = manager.prepare(Settings(**answers))
96
+ say("\nReady to start")
97
+ say(f" Machine {answers['alias']}\n Local 127.0.0.1:{answers['local_port']}"
98
+ f"\n Destination {answers['destination']}:{answers['remote_port']} on {answers['alias']}"
99
+ f"\n Mode {answers['mode'].capitalize()}")
100
+ if restart:
101
+ say(f"Restart will stop tunnel {restart['id']} before starting this route. Existing connections will be interrupted.")
102
+ show_commands(record)
103
+ action = choose("Review", ["Start tunnel", "Edit settings", "Cancel"])
104
+ if action == "Cancel":
105
+ return False
106
+ if action == "Edit settings":
107
+ editing = True
108
+ step = choose("Edit which setting?", [Choice(name, index) for index, name in enumerate(fields)])
109
+ continue
110
+ current = manager.records()
111
+ for existing in current:
112
+ if existing["status"] == "Running" and same_route(existing["settings"], answers) and (not restart or existing["id"] != restart["id"]):
113
+ say("That route is already running. Opening its management actions.")
114
+ return manage(manager, existing["id"])
115
+ if restart:
116
+ if answers["alias"] not in discover_hosts(manager.config_path).aliases:
117
+ raise TunnelError("Selected alias is no longer discoverable. Edit Machine before restarting; existing tunnel was left running.")
118
+ original = next((item for item in current if item["id"] == restart["id"]), None)
119
+ owns_port = original and original["status"] == "Running" and original["settings"]["local_port"] == answers["local_port"]
120
+ if not owns_port and not available_port(answers["local_port"]):
121
+ raise TunnelError("Replacement port is unavailable. Edit Local port; existing tunnel was left running.")
122
+ manager.stop(restart["id"])
123
+ restart = None
124
+ say(f"Connecting to {answers['alias']}… SSH may request authentication.")
125
+ result = manager.start(record, on_started=_started)
126
+ if answers["mode"] == "foreground":
127
+ say(f"Tunnel {result['status'].lower()}.")
128
+ else:
129
+ try:
130
+ action = choose("Next", ["Return to shell", "Open as HTTP website", "Open as HTTPS website"])
131
+ if action != "Return to shell":
132
+ scheme = "https" if action == "Open as HTTPS website" else "http"
133
+ if not webbrowser.open(f"{scheme}://127.0.0.1:{answers['local_port']}"):
134
+ say("Could not open the browser. Open the local address manually.")
135
+ except (Back, KeyboardInterrupt):
136
+ say("Tunnel remains running in the background.")
137
+ return True
138
+ step = 5 if editing else step + 1
139
+ editing = False
140
+ except Back:
141
+ if editing:
142
+ step, editing = 5, False
143
+ elif step == 0:
144
+ return False
145
+ else:
146
+ step -= 1
147
+ except (TunnelError, OSError, ValueError) as exc:
148
+ say(f"Could not start tunnel: {exc}")
149
+ # Review is rebuilt for every attempt, so retry cannot reuse a reserved ID.
150
+
151
+
152
+ def manage(manager: Manager, tunnel_id: str | None = None, action: str | None = None) -> bool:
153
+ while True:
154
+ try:
155
+ records = manager.records()
156
+ if not records:
157
+ say("No managed tunnels yet.")
158
+ return False
159
+ if tunnel_id is None:
160
+ tunnel_id = choose("Manage tunnels", [Choice(label(item), item["id"]) for item in records] + [Choice("Back", "back")])
161
+ if tunnel_id == "back":
162
+ return False
163
+ record = next((item for item in records if item["id"] == tunnel_id), None)
164
+ if record is None:
165
+ raise TunnelError("Tunnel not found. Use tunnel list for managed IDs.")
166
+ selected = action or choose(label(record), ["Details", "Show command", "View logs", "Stop tunnel", "Restart tunnel", "Back"])
167
+ if selected == "Details":
168
+ show_details(record)
169
+ elif selected == "Show command":
170
+ show_commands(record)
171
+ elif selected == "View logs":
172
+ say(manager.logs(tunnel_id))
173
+ elif selected in ("Stop tunnel", "stop"):
174
+ if choose(f"Stop {record['settings']['alias']} on local port {record['settings']['local_port']}?", ["Cancel", "Stop tunnel"]) == "Stop tunnel":
175
+ manager.stop(tunnel_id)
176
+ say("Tunnel stopped.")
177
+ elif selected in ("Restart tunnel", "restart"):
178
+ return run_wizard(manager, record["settings"], restart=record)
179
+ else:
180
+ return False
181
+ if action:
182
+ return False
183
+ except Back:
184
+ return False
185
+
186
+
187
+ def recent(manager: Manager) -> bool:
188
+ records = [item for item in reversed(manager.records()) if item["successful"]]
189
+ unique = {}
190
+ for item in records:
191
+ unique.setdefault(tuple(sorted(item["settings"].items())), item)
192
+ if not unique:
193
+ say("No successful tunnels to reuse yet.")
194
+ return False
195
+ try:
196
+ item = choose("Recent tunnels", [Choice(label(record), record) for record in unique.values()])
197
+ if item["status"] == "Running":
198
+ return manage(manager, item["id"])
199
+ return run_wizard(manager, item["settings"])
200
+ except Back:
201
+ return False
202
+
203
+
204
+ def home(manager: Manager):
205
+ while True:
206
+ try:
207
+ count = sum(item["status"] == "Running" for item in manager.records())
208
+ choice = choose("What would you like to do?", ["New tunnel", f"Manage tunnels · {count} running", "Recent tunnels", "Exit"])
209
+ if choice == "Exit":
210
+ return
211
+ if choice == "New tunnel":
212
+ finished = run_wizard(manager)
213
+ elif choice == "Recent tunnels":
214
+ finished = recent(manager)
215
+ else:
216
+ finished = manage(manager)
217
+ if finished:
218
+ return
219
+ except Back:
220
+ return
221
+
222
+
223
+ def main(argv: list[str] | None = None) -> int:
224
+ parser = argparse.ArgumentParser(description="Guided local SSH tunnels using existing config aliases.")
225
+ parser.add_argument("--config", type=Path, help="SSH config to use (default: ~/.ssh/config)")
226
+ parser.add_argument("--state-dir", type=Path, help="Private, short state directory (default: ~/.tunnel)")
227
+ parser.add_argument("--version", action="version", version="tunnel 0.1.1")
228
+ commands = parser.add_subparsers(dest="command")
229
+ commands.add_parser("list", help="Print owned tunnel status without prompting")
230
+ for name in ("stop", "restart"):
231
+ command = commands.add_parser(name, help=f"Interactively {name} a managed tunnel")
232
+ command.add_argument("id", nargs="?", help="Tunnel ID; omit to select interactively")
233
+ args = parser.parse_args(argv)
234
+ if args.command != "list" and (not sys.stdin.isatty() or not sys.stdout.isatty()):
235
+ say("Interactive commands need a terminal. Use tunnel list for noninteractive status.")
236
+ return 2
237
+ try:
238
+ manager = Manager(args.config, args.state_dir)
239
+ if args.command == "list":
240
+ records = manager.records()
241
+ if not records:
242
+ say("No managed tunnels.")
243
+ else:
244
+ say("ID MACHINE / LOCAL → DESTINATION / STATUS")
245
+ for record in records:
246
+ say(f"{record['id']} {label(record)}")
247
+ elif args.command:
248
+ manage(manager, args.id, args.command)
249
+ else:
250
+ home(manager)
251
+ return 0
252
+ except KeyboardInterrupt:
253
+ say("\nCancelled.")
254
+ return 130
255
+ except Back:
256
+ return 0
257
+ except (TunnelError, OSError, ValueError) as exc:
258
+ say(f"Error: {exc}")
259
+ return 1
260
+
261
+
262
+ if __name__ == "__main__":
263
+ raise SystemExit(main())
tunnel_cli/config.py ADDED
@@ -0,0 +1,105 @@
1
+ """Discover explicit SSH aliases without evaluating SSH conditions or commands."""
2
+
3
+ import glob
4
+ import re
5
+ import shlex
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class Discovery:
12
+ aliases: list[str] = field(default_factory=list)
13
+ warnings: list[str] = field(default_factory=list)
14
+
15
+
16
+ def is_alias(value: str) -> bool:
17
+ """Accept literal aliases that cannot be interpreted as SSH options."""
18
+ return re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]*", value) is not None
19
+
20
+
21
+ def discover_hosts(config_path: Path | None = None) -> Discovery:
22
+ """Read aliases, conservatively skipping conditional and dynamic Includes.
23
+
24
+ Relative user Includes use ~/.ssh, including with a custom config path.
25
+ Included Host/Match directives affect the following parent-file lines.
26
+ This is a discovery reader, not an evaluator of OpenSSH configuration.
27
+ """
28
+ config_path = config_path or Path.home() / ".ssh" / "config"
29
+ base = Path.home() / ".ssh"
30
+ result = Discovery()
31
+ seen: set[str] = set()
32
+ active: set[Path] = set()
33
+
34
+ def read(path: Path, unconditional: bool, depth: int) -> bool:
35
+ try:
36
+ path = path.resolve()
37
+ if path in active:
38
+ result.warnings.append(f"{path}: Include cycle skipped; host list may be incomplete.")
39
+ return unconditional
40
+ if depth > 16:
41
+ result.warnings.append(f"{path}: Include depth limit reached; host list may be incomplete.")
42
+ return unconditional
43
+ lines = path.read_text(encoding="utf-8").splitlines()
44
+ except (OSError, UnicodeError, RuntimeError) as exc:
45
+ result.warnings.append(f"{path}: Cannot read SSH config ({exc}); host list may be incomplete.")
46
+ return unconditional
47
+
48
+ active.add(path)
49
+ try:
50
+ for number, line in enumerate(lines, 1):
51
+ match = re.match(r"\s*([^\s=#]+)(?:\s*=\s*|\s+)?(.*)", line)
52
+ if not match:
53
+ continue
54
+ keyword, arguments = match.groups()
55
+ keyword = keyword.lower()
56
+ if keyword not in {"host", "match", "include"}:
57
+ continue
58
+ location = f"{path}:{number}"
59
+ try:
60
+ values = shlex.split(arguments, comments=True)
61
+ if not values:
62
+ raise ValueError("missing argument")
63
+ except ValueError as exc:
64
+ result.warnings.append(f"{location}: Invalid {keyword} ({exc}); host list may be incomplete.")
65
+ if keyword in {"host", "match"}:
66
+ unconditional = False
67
+ continue
68
+
69
+ if keyword == "host":
70
+ unconditional = values == ["*"]
71
+ for alias in values:
72
+ if any(character in alias for character in "*?!"):
73
+ continue
74
+ if not is_alias(alias):
75
+ result.warnings.append(f"{location}: Unsupported alias {alias!r} skipped.")
76
+ elif alias not in seen:
77
+ seen.add(alias)
78
+ result.aliases.append(alias)
79
+ elif keyword == "match":
80
+ unconditional = len(values) == 1 and values[0].lower() == "all"
81
+ elif not unconditional:
82
+ result.warnings.append(f"{location}: Conditional Include skipped; host list may be incomplete.")
83
+ else:
84
+ for value in values:
85
+ if "$" in value or "%" in value:
86
+ result.warnings.append(f"{location}: Dynamic Include {value!r} skipped; host list may be incomplete.")
87
+ continue
88
+ try:
89
+ pattern = Path(value).expanduser()
90
+ except (KeyError, RuntimeError) as exc:
91
+ result.warnings.append(f"{location}: Cannot expand Include {value!r} ({exc}).")
92
+ continue
93
+ if not pattern.is_absolute():
94
+ pattern = base / pattern
95
+ paths = sorted(glob.glob(str(pattern)))
96
+ if not paths:
97
+ result.warnings.append(f"{location}: Include {value!r} matched no files; host list may be incomplete.")
98
+ for included in paths:
99
+ unconditional = read(Path(included), unconditional, depth + 1)
100
+ finally:
101
+ active.remove(path)
102
+ return unconditional
103
+
104
+ read(config_path.expanduser(), True, 0)
105
+ return result
tunnel_cli/core.py ADDED
@@ -0,0 +1,402 @@
1
+ """Native SSH lifecycle and private, durable ownership records."""
2
+
3
+ from contextlib import contextmanager
4
+ from dataclasses import asdict, dataclass
5
+ from datetime import datetime, timezone
6
+ import errno
7
+ import fcntl
8
+ import ipaddress
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import signal
14
+ import socket
15
+ import stat
16
+ import subprocess
17
+ import time
18
+ from typing import Callable
19
+ import uuid
20
+
21
+ from tunnel_cli.config import discover_hosts, is_alias
22
+
23
+
24
+ class TunnelError(Exception):
25
+ """An actionable failure, suitable for displaying without a traceback."""
26
+
27
+
28
+ def validate_port(value: str | int) -> int:
29
+ if isinstance(value, bool) or not re.fullmatch(r"[0-9]{1,5}", str(value)):
30
+ raise ValueError("Enter a decimal port from 1 to 65535.")
31
+ port = int(value)
32
+ if not 1 <= port <= 65535:
33
+ raise ValueError("Enter a decimal port from 1 to 65535.")
34
+ return port
35
+
36
+
37
+ def validate_destination(value: str) -> str:
38
+ if not isinstance(value, str):
39
+ raise ValueError("Destination must be a hostname or IP address string.")
40
+ try:
41
+ return str(ipaddress.ip_address(value))
42
+ except ValueError:
43
+ if len(value) > 253 or not all(
44
+ re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", part)
45
+ for part in value.rstrip(".").split(".")
46
+ ):
47
+ raise ValueError("Enter a hostname or IP address, without a port or URL.") from None
48
+ return value
49
+
50
+
51
+ def available_port(port: int) -> bool:
52
+ with socket.socket() as listener:
53
+ try:
54
+ listener.bind(("127.0.0.1", validate_port(port)))
55
+ except OSError:
56
+ return False
57
+ return True
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Settings:
62
+ alias: str
63
+ remote_port: int
64
+ local_port: int
65
+ destination: str = "127.0.0.1"
66
+ mode: str = "background"
67
+
68
+ def __post_init__(self):
69
+ if not isinstance(self.alias, str) or not is_alias(self.alias):
70
+ raise ValueError("Choose a literal SSH-config alias.")
71
+ validate_port(self.remote_port)
72
+ validate_port(self.local_port)
73
+ if type(self.remote_port) is not int or type(self.local_port) is not int:
74
+ raise ValueError("Stored ports must be integers.")
75
+ validate_destination(self.destination)
76
+ if self.mode not in ("background", "foreground"):
77
+ raise ValueError("Run mode must be background or foreground.")
78
+
79
+
80
+ def now() -> str:
81
+ return datetime.now(timezone.utc).isoformat()
82
+
83
+
84
+ def _interrupt(*_):
85
+ raise KeyboardInterrupt
86
+
87
+
88
+ class Manager:
89
+ def __init__(self, config_path: Path | None = None, state_dir: Path | None = None):
90
+ self.config_path = Path(config_path).expanduser().absolute() if config_path else None
91
+ self.state_dir = Path(state_dir or Path.home() / ".tunnel").expanduser().absolute()
92
+ self.ssh = "/usr/bin/ssh"
93
+ if not Path(self.ssh).is_file():
94
+ raise TunnelError("System OpenSSH was not found at /usr/bin/ssh.")
95
+ self.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
96
+ info = self.state_dir.lstat()
97
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
98
+ raise TunnelError(f"State directory must be owned by you, private (0700), and not a symlink: {self.state_dir}")
99
+ if len(os.fsencode(self.state_dir / ('0' * 12 + '.sock'))) >= 100:
100
+ raise TunnelError("State path is too long for SSH control sockets. Choose a shorter --state-dir.")
101
+
102
+ def _path(self, tunnel_id: str, suffix: str) -> Path:
103
+ if not re.fullmatch(r"[0-9a-f]{12}", tunnel_id):
104
+ raise TunnelError("Invalid tunnel ID.")
105
+ return self.state_dir / f"{tunnel_id}.{suffix}"
106
+
107
+ def _open(self, path: Path, flags: int):
108
+ fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
109
+ info = os.fstat(fd)
110
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1:
111
+ os.close(fd)
112
+ raise TunnelError(f"Unsafe state file (expected a private regular file): {path}")
113
+ return fd
114
+
115
+ @contextmanager
116
+ def _locked(self):
117
+ fd = self._open(self.state_dir / "lock", os.O_RDWR | os.O_CREAT)
118
+ try:
119
+ try:
120
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
121
+ except BlockingIOError:
122
+ raise TunnelError("Another tunnel command is changing state. Try again when it finishes.") from None
123
+ yield
124
+ finally:
125
+ os.close(fd)
126
+
127
+ def _read(self) -> list[dict]:
128
+ try:
129
+ fd = self._open(self.state_dir / "registry.json", os.O_RDONLY)
130
+ except FileNotFoundError:
131
+ return []
132
+ try:
133
+ with os.fdopen(fd) as file:
134
+ data = json.load(file)
135
+ if data["version"] != 1 or not isinstance(data["tunnels"], list):
136
+ raise ValueError("unsupported schema")
137
+ ids = set()
138
+ for record in data["tunnels"]:
139
+ self._path(record["id"], "sock")
140
+ if record["id"] in ids:
141
+ raise ValueError("duplicate tunnel ID")
142
+ ids.add(record["id"])
143
+ Settings(**record["settings"])
144
+ if not isinstance(record["created_at"], str):
145
+ raise ValueError("invalid creation time")
146
+ if record.get("last_error") is not None and not isinstance(record["last_error"], str):
147
+ raise ValueError("invalid diagnostic")
148
+ if record["status"] not in ("Connecting", "Running", "Stopped", "Disconnected", "Failed", "Unknown"):
149
+ raise ValueError("unknown status")
150
+ if not isinstance(record["successful"], bool) or not isinstance(record["commands"], list):
151
+ raise ValueError("invalid record")
152
+ if not record["commands"] or any(
153
+ not isinstance(command, list) or not command or not all(isinstance(arg, str) for arg in command)
154
+ for command in record["commands"]
155
+ ):
156
+ raise ValueError("invalid saved command")
157
+ return data["tunnels"]
158
+ except (ValueError, TypeError, KeyError, TunnelError) as exc:
159
+ raise TunnelError(f"Cannot read registry; preserved it unchanged: {exc}") from exc
160
+
161
+ def _save(self, records: list[dict]):
162
+ pending = self.state_dir / "registry.pending"
163
+ fd = self._open(pending, os.O_WRONLY | os.O_CREAT)
164
+ with os.fdopen(fd, "w") as file:
165
+ file.truncate(0)
166
+ json.dump({"version": 1, "tunnels": records}, file, indent=2)
167
+ file.flush()
168
+ os.fsync(file.fileno())
169
+ os.replace(pending, self.state_dir / "registry.json")
170
+ directory = os.open(self.state_dir, os.O_RDONLY)
171
+ try:
172
+ os.fsync(directory)
173
+ finally:
174
+ os.close(directory)
175
+
176
+ def _control_args(self, record: dict, operation: str) -> list[str]:
177
+ # Management must never re-evaluate user configuration or fall back to a new connection.
178
+ return [self.ssh, "-F", "/dev/null", "-S", str(self._path(record["id"], "sock")),
179
+ "-O", operation, record["settings"]["alias"]]
180
+
181
+ def _control(self, record: dict, operation: str) -> subprocess.CompletedProcess:
182
+ return subprocess.run(self._control_args(record, operation), capture_output=True, text=True, timeout=5)
183
+
184
+ def _probe(self, record: dict) -> str:
185
+ path = self._path(record["id"], "sock")
186
+ try:
187
+ info = path.lstat()
188
+ if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid():
189
+ return "Unknown"
190
+ result = self._control(record, "check")
191
+ if result.returncode == 0:
192
+ return "Alive"
193
+ # Distinguish stale/unlinked sockets from inspection failures.
194
+ with socket.socket(socket.AF_UNIX) as client:
195
+ client.settimeout(1)
196
+ try:
197
+ client.connect(str(path))
198
+ except OSError as exc:
199
+ if exc.errno in (errno.ENOENT, errno.ECONNREFUSED):
200
+ return "Gone"
201
+ return "Unknown"
202
+ except FileNotFoundError:
203
+ return "Gone"
204
+ except (OSError, subprocess.TimeoutExpired):
205
+ return "Unknown"
206
+
207
+ def _status(self, record: dict) -> str:
208
+ probe = self._probe(record)
209
+ if probe == "Unknown":
210
+ return "Unknown"
211
+ if probe == "Alive":
212
+ return "Running" if record["status"] == "Running" else "Unknown"
213
+ return record["status"] if record["status"] in ("Stopped", "Failed") else "Disconnected"
214
+
215
+ def records(self) -> list[dict]:
216
+ return [dict(record, status=self._status(record)) for record in self._read()]
217
+
218
+ def commands(self, record: dict) -> list[list[str]]:
219
+ settings = Settings(**record["settings"])
220
+ master = [self.ssh]
221
+ if self.config_path:
222
+ master += ["-F", str(self.config_path)]
223
+ master += ["-n", "-N", "-T", "-M", "-S", str(self._path(record["id"], "sock")),
224
+ "-E", str(self._path(record["id"], "log"))]
225
+ for option in ("ClearAllForwardings=yes", "ControlPersist=no", "ExitOnForwardFailure=yes",
226
+ "PermitLocalCommand=no", "RemoteCommand=none", "ForwardAgent=no",
227
+ "ForwardX11=no", "Tunnel=no", "ServerAliveInterval=15", "ServerAliveCountMax=3",
228
+ "LogLevel=ERROR", "ConnectTimeout=15", "ConnectionAttempts=1",
229
+ f"ForkAfterAuthentication={'yes' if settings.mode == 'background' else 'no'}"):
230
+ master += ["-o", option]
231
+ master += [settings.alias]
232
+ destination = f"[{settings.destination}]" if ":" in settings.destination else settings.destination
233
+ forward = self._control_args(record, "forward")
234
+ forward[-1:-1] = ["-o", "ClearAllForwardings=no", "-L",
235
+ f"127.0.0.1:{settings.local_port}:{destination}:{settings.remote_port}"]
236
+ return [master, forward]
237
+
238
+ def prepare(self, settings: Settings) -> dict:
239
+ record = {"id": uuid.uuid4().hex[:12], "settings": asdict(settings), "created_at": now(),
240
+ "status": "Connecting", "successful": False, "last_error": None}
241
+ record["commands"] = self.commands(record)
242
+ return record
243
+
244
+ def _find(self, records: list[dict], tunnel_id: str) -> dict:
245
+ self._path(tunnel_id, "sock")
246
+ for record in records:
247
+ if record["id"] == tunnel_id:
248
+ return record
249
+ raise TunnelError("Tunnel not found. Use tunnel list to see managed IDs.")
250
+
251
+ def logs(self, tunnel_id: str) -> str:
252
+ self._find(self._read(), tunnel_id)
253
+ path = self._path(tunnel_id, "log")
254
+ try:
255
+ fd = self._open(path, os.O_RDWR)
256
+ except FileNotFoundError:
257
+ return "No SSH diagnostics recorded. Application logs are not collected."
258
+ with os.fdopen(fd, "r+b") as file:
259
+ file.seek(0, os.SEEK_END)
260
+ size = file.tell()
261
+ file.seek(max(0, size - 65536))
262
+ output = file.read()
263
+ # shortcut: SSH owns the log; trim on access, add rotation if continuous log growth matters.
264
+ if size > 65536:
265
+ file.seek(0)
266
+ file.write(output)
267
+ file.truncate()
268
+ return output.decode(errors="replace") or "No SSH diagnostics recorded. Application logs are not collected."
269
+
270
+ def _exit(self, record: dict):
271
+ probe = self._probe(record)
272
+ if probe == "Unknown":
273
+ raise TunnelError("Cannot verify the owned control socket. No process was killed.")
274
+ if probe == "Gone":
275
+ return
276
+ try:
277
+ result = self._control(record, "exit")
278
+ except subprocess.TimeoutExpired as exc:
279
+ raise TunnelError("Timed out stopping the owned SSH connection; inspect its status.") from exc
280
+ for _ in range(30):
281
+ if self._probe(record) == "Gone":
282
+ return
283
+ time.sleep(0.1)
284
+ raise TunnelError(result.stderr.strip() or "SSH shutdown could not be verified.")
285
+
286
+ def stop(self, tunnel_id: str) -> dict:
287
+ with self._locked():
288
+ records = self._read()
289
+ record = self._find(records, tunnel_id)
290
+ self._exit(record)
291
+ record.update(status="Stopped", stopped_at=now(), last_error=None)
292
+ self._save(records)
293
+ return dict(record)
294
+
295
+ def _cleanup_attempt(self, record: dict, process: subprocess.Popen | None):
296
+ if process is not None and process.poll() is None:
297
+ process.terminate()
298
+ try:
299
+ process.wait(timeout=5)
300
+ except subprocess.TimeoutExpired:
301
+ process.kill()
302
+ process.wait()
303
+ self._exit(record)
304
+
305
+ def start(self, record: dict, on_started: Callable[[dict], None] | None = None) -> dict:
306
+ settings = Settings(**record["settings"])
307
+ process = None
308
+ previous_hup = None
309
+ try:
310
+ previous_hup = signal.signal(signal.SIGHUP, _interrupt)
311
+ with self._locked():
312
+ records = self._read()
313
+ if settings.alias not in discover_hosts(self.config_path).aliases:
314
+ raise TunnelError("The selected alias is no longer in the discovered SSH configuration.")
315
+ if any(item["id"] == record["id"] for item in records):
316
+ raise TunnelError("This launch was already attempted. Review a fresh attempt before retrying.")
317
+ for existing in records:
318
+ route = ("alias", "destination", "local_port", "remote_port")
319
+ if all(existing["settings"][key] == record["settings"][key] for key in route) and self._probe(existing) == "Alive":
320
+ raise TunnelError(f"This tunnel is already active: {existing['id']}. Open Manage tunnels.")
321
+ if not available_port(settings.local_port):
322
+ raise TunnelError(f"Port {settings.local_port} is unavailable. Choose another local port.")
323
+ if self._path(record["id"], "sock").exists():
324
+ raise TunnelError("Control path already exists. Review a fresh attempt.")
325
+ if record["commands"] != self.commands(record):
326
+ raise TunnelError("Launch settings changed after review. Review again before starting.")
327
+ record = dict(record, status="Connecting", started_at=now())
328
+ records.append(record)
329
+ self._save(records)
330
+ try:
331
+ fd = self._open(self._path(record["id"], "log"), os.O_WRONLY | os.O_CREAT | os.O_EXCL)
332
+ os.close(fd)
333
+ process = subprocess.Popen(record["commands"][0])
334
+ while self._probe(record) != "Alive":
335
+ if process.poll() is not None:
336
+ # A background master may publish its socket just after its parent exits.
337
+ if settings.mode == "background" and process.returncode == 0:
338
+ for _ in range(30):
339
+ if self._probe(record) == "Alive":
340
+ break
341
+ time.sleep(0.1)
342
+ else:
343
+ raise TunnelError("SSH exited without a responsive owned control socket.")
344
+ break
345
+ raise TunnelError(f"SSH startup failed (exit {process.returncode}). {self.logs(record['id'])}")
346
+ time.sleep(0.05)
347
+ if settings.mode == "background":
348
+ process.wait(timeout=5)
349
+ if process.returncode:
350
+ raise TunnelError(f"SSH backgrounding failed (exit {process.returncode}).")
351
+ result = subprocess.run(record["commands"][1], capture_output=True, text=True, timeout=10)
352
+ if result.returncode:
353
+ raise TunnelError(result.stderr.strip() or "SSH refused the requested forward.")
354
+ if self._probe(record) != "Alive":
355
+ raise TunnelError("SSH disconnected while opening the forward.")
356
+ record.update(status="Running", successful=True, last_error=None)
357
+ self._save(records)
358
+ except BaseException as exc:
359
+ try:
360
+ self._cleanup_attempt(record, process)
361
+ record["status"] = "Failed"
362
+ except (TunnelError, OSError) as cleanup_error:
363
+ record["status"] = "Unknown"
364
+ record["last_error"] = f"Cleanup needs attention: {cleanup_error}"
365
+ record["last_error"] = record["last_error"] or ("Cancelled during startup." if isinstance(exc, KeyboardInterrupt) else str(exc))
366
+ record["successful"] = False
367
+ try:
368
+ self._save(records)
369
+ except OSError as save_error:
370
+ raise TunnelError(f"Launch failed; could not save its outcome. Record {record['id']} was reserved before launch. {save_error}") from exc
371
+ if isinstance(exc, (KeyboardInterrupt, SystemExit)):
372
+ raise
373
+ raise TunnelError(record["last_error"]) from exc
374
+ if settings.mode == "foreground":
375
+ interrupted = False
376
+ try:
377
+ if on_started:
378
+ on_started(dict(record))
379
+ try:
380
+ process.wait()
381
+ except KeyboardInterrupt:
382
+ interrupted = True
383
+ finally:
384
+ stopped = interrupted or process.poll() is None
385
+ # Release the owned connection even if updating the registry later fails.
386
+ self._exit(record)
387
+ process.wait(timeout=5)
388
+ with self._locked():
389
+ records = self._read()
390
+ stored = self._find(records, record["id"])
391
+ if stopped:
392
+ stored.update(status="Stopped", stopped_at=now(), last_error=None)
393
+ elif stored["status"] != "Stopped":
394
+ stored.update(status="Disconnected", last_error=f"SSH exited ({process.returncode}).")
395
+ self._save(records)
396
+ return dict(stored)
397
+ if on_started:
398
+ on_started(dict(record))
399
+ return dict(record)
400
+ finally:
401
+ if previous_hup is not None:
402
+ signal.signal(signal.SIGHUP, previous_hup)
tunnel_cli/prompts.py ADDED
@@ -0,0 +1,120 @@
1
+ """Small prompt layer over Questionary: Esc raises Back, search never submits
2
+ an unmatched host, and say() prints untrusted text without control sequences."""
3
+
4
+ import os
5
+ import re
6
+
7
+ import questionary
8
+ from prompt_toolkit.filters import Condition
9
+ from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
10
+ from prompt_toolkit.output import ColorDepth
11
+ from prompt_toolkit.styles import Style
12
+ from prompt_toolkit.validation import ValidationError, Validator
13
+ from questionary.prompts.common import InquirerControl
14
+
15
+ _HINT = "Esc back · Ctrl+C cancel"
16
+
17
+
18
+ class Back(Exception):
19
+ """The user pressed Esc to go back one step; collected answers stay valid."""
20
+
21
+
22
+ _UNSAFE = re.compile(
23
+ r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (title/hyperlink injection)
24
+ r"|\x1b\[[0-?]*[ -/]*[@-~]?" # CSI sequences
25
+ r"|\x1b.?" # any other escape, even truncated
26
+ r"|[\x00-\x08\x0b-\x1f\x7f\x80-\x9f]" # C0 except \t\n, DEL, C1
27
+ )
28
+
29
+
30
+ def say(message: str = "") -> None:
31
+ """Print possibly untrusted text (SSH diagnostics, config warnings) with
32
+ terminal control characters removed; newlines and tabs survive."""
33
+ print(_UNSAFE.sub("", str(message)))
34
+
35
+
36
+ def _style() -> Style:
37
+ return Style(
38
+ [("qmark", "fg:ansicyan"), ("pointer", "fg:ansicyan"),
39
+ ("highlighted", "fg:ansicyan"), ("answer", "fg:ansicyan")]
40
+ )
41
+
42
+
43
+ def _color() -> dict:
44
+ """NO_COLOR present (even empty) forces monochrome rendering. A Style([])
45
+ would merge with Questionary's defaults and not suppress colors; the
46
+ application's color depth does."""
47
+ return {"color_depth": ColorDepth.DEPTH_1_BIT} if "NO_COLOR" in os.environ else {}
48
+
49
+
50
+ class _Checked(Validator):
51
+ """Adapt a callable that raises ValueError (or returns False) for invalid text."""
52
+
53
+ def __init__(self, check):
54
+ self._check = check
55
+
56
+ def validate(self, document):
57
+ try:
58
+ ok = self._check(document.text)
59
+ except ValueError as error:
60
+ raise ValidationError(message=str(error), cursor_position=len(document.text)) from None
61
+ if ok is False:
62
+ raise ValidationError(message="Invalid input.", cursor_position=len(document.text))
63
+
64
+
65
+ def _ask(question, *extra_bindings):
66
+ """Run a questionary question with a shared Esc-goes-back binding."""
67
+ application = question.application
68
+ back = KeyBindings()
69
+
70
+ @back.add("escape", eager=True)
71
+ def _back(event):
72
+ event.app.exit(exception=Back())
73
+
74
+ application.key_bindings = merge_key_bindings(
75
+ [bindings for bindings in (application.key_bindings, back, *extra_bindings) if bindings]
76
+ )
77
+ application.ttimeoutlen = 0.1 # resolve a lone Esc quickly
78
+ return question.unsafe_ask()
79
+
80
+
81
+ def choose(message: str, choices: list, default=None, search: bool = False, **io):
82
+ """Arrow-key menu. With search=True, typing filters the choices and Enter is
83
+ ignored while the filter matches nothing, so an invisible choice can never
84
+ be submitted and typed text is never the answer. Raises Back on Esc."""
85
+ hint = ("type to filter · " if search else "") + f"↑↓ navigate · Enter select · {_HINT}"
86
+ question = questionary.select(
87
+ message, choices=choices, default=default, style=_style(),
88
+ instruction=f"({hint})",
89
+ use_search_filter=search, use_jk_keys=False, use_shortcuts=False,
90
+ **_color(), **io,
91
+ )
92
+ extra = []
93
+ if search:
94
+ control = next(
95
+ window.content
96
+ for window in question.application.layout.find_all_windows()
97
+ if isinstance(window.content, InquirerControl)
98
+ )
99
+
100
+ @Condition
101
+ def unmatched():
102
+ # shortcut: pinned Questionary 2.1.1 control internals, recheck on upgrade.
103
+ needle = (control.search_filter or "").lower()
104
+ return bool(needle) and not any(
105
+ needle in choice.title.lower() for choice in control.choices
106
+ )
107
+
108
+ block = KeyBindings()
109
+ block.add("enter", eager=True, filter=unmatched)(lambda event: None)
110
+ extra.append(block)
111
+ return _ask(question, *extra)
112
+
113
+
114
+ def text(message: str, default: str = "", validate=None, **io) -> str:
115
+ """One-line answer with optional validation; Esc raises Back."""
116
+ question = questionary.text(
117
+ message, default=default, style=_style(), instruction=f"({_HINT}) ",
118
+ validate=_Checked(validate) if validate else None, **_color(), **io,
119
+ )
120
+ return _ask(question)