diff --git a/README.md b/README.md index 6fd581e..893bf92 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,17 @@ deepseek-cursor-proxy When ngrok is enabled, `deepseek-cursor-proxy` will print the ngrok public URL on start. If it differs from the one in Cursor, update it in Cursor's Base URL field. +If you use a **reserved ngrok endpoint or your own domain** (instead of a URL assigned by ngrok), pass it through to the ngrok agent as `--url=…`. Set `ngrok_url` in `~/.deepseek-cursor-proxy/config.yaml` or use `--ngrok-url` on the command line (see `ngrok http --help`). Example: + +```yaml +ngrok: true +ngrok_url: https://your-subdomain.ngrok.dev +``` + +```bash +deepseek-cursor-proxy --ngrok-url https://your-subdomain.ngrok.dev +``` + On the first run, `deepseek-cursor-proxy` will create: - `~/.deepseek-cursor-proxy/config.yaml`: the configuration file @@ -99,6 +110,9 @@ deepseek-cursor-proxy --verbose # Run without ngrok (run on localhost directly) deepseek-cursor-proxy --no-ngrok +# Use a fixed ngrok public URL (reserved endpoint / custom domain) +deepseek-cursor-proxy --ngrok-url https://your-subdomain.ngrok.dev + # Use a different local port deepseek-cursor-proxy --port 9000 ``` diff --git a/src/deepseek_cursor_proxy/config.py b/src/deepseek_cursor_proxy/config.py index ba2b883..d30438e 100644 --- a/src/deepseek_cursor_proxy/config.py +++ b/src/deepseek_cursor_proxy/config.py @@ -119,6 +119,13 @@ def as_str(value: Any, default: str) -> str: return str(value) +def as_optional_str(value: Any) -> str | None: + if value is MISSING or value is None: + return None + stripped = str(value).strip() + return stripped if stripped else None + + def as_bool(value: Any, default: bool) -> bool: if value is MISSING or value is None: return default @@ -203,6 +210,7 @@ class ProxyConfig: cors: bool = DEFAULT_CORS verbose: bool = DEFAULT_VERBOSE ngrok: bool = DEFAULT_NGROK + ngrok_url: str | None = None trace_dir: Path | None = None @classmethod @@ -283,4 +291,5 @@ def from_file( setting_value(settings, "ngrok"), DEFAULT_NGROK, ), + ngrok_url=as_optional_str(setting_value(settings, "ngrok_url")), ) diff --git a/src/deepseek_cursor_proxy/server.py b/src/deepseek_cursor_proxy/server.py index 2594689..0202062 100644 --- a/src/deepseek_cursor_proxy/server.py +++ b/src/deepseek_cursor_proxy/server.py @@ -892,6 +892,14 @@ def build_arg_parser() -> argparse.ArgumentParser: default=None, help="Start an ngrok tunnel and print the Cursor base URL", ) + parser.add_argument( + "--ngrok-url", + metavar="URL", + help=( + "Pass --url=URL to ngrok (reserved endpoint / custom domain); " + "see `ngrok http --help`" + ), + ) parser.add_argument( "--verbose", action=argparse.BooleanOptionalAction, @@ -1260,6 +1268,9 @@ def main(argv: list[str] | None = None) -> int: updates["reasoning_content_path"] = args.reasoning_content_path if args.ngrok is not None: updates["ngrok"] = args.ngrok + if args.ngrok_url is not None: + stripped = str(args.ngrok_url).strip() + updates["ngrok_url"] = stripped if stripped else None if args.verbose is not None: updates["verbose"] = args.verbose if args.trace_dir is not None: @@ -1314,7 +1325,7 @@ def main(argv: list[str] | None = None) -> int: public_url: str | None = None if config.ngrok: target_url = local_tunnel_target(config.host, config.port) - tunnel = NgrokTunnel(target_url) + tunnel = NgrokTunnel(target_url, ngrok_url=config.ngrok_url) try: public_url = tunnel.start() except RuntimeError as exc: diff --git a/src/deepseek_cursor_proxy/tunnel.py b/src/deepseek_cursor_proxy/tunnel.py index 005b134..2516ec1 100644 --- a/src/deepseek_cursor_proxy/tunnel.py +++ b/src/deepseek_cursor_proxy/tunnel.py @@ -57,6 +57,7 @@ def ngrok_agent_urls(api_url: str) -> list[str]: @dataclass class NgrokTunnel: target_url: str + ngrok_url: str | None = None command: str = "ngrok" api_url: str = DEFAULT_NGROK_API_URL startup_timeout: float = 15.0 @@ -70,8 +71,12 @@ def start(self) -> str: "`ngrok config add-authtoken ` once." ) + argv = [self.command, "http", self.target_url] + if self.ngrok_url: + argv.append(f"--url={self.ngrok_url}") + self.process = subprocess.Popen( - [self.command, "http", self.target_url], + argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) diff --git a/tests/test_config.py b/tests/test_config.py index 118ee67..5964df5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -40,6 +40,7 @@ def test_default_paths_live_in_visible_user_app_directory(self) -> None: home / ".deepseek-cursor-proxy" / "reasoning_content.sqlite3", ) self.assertEqual(ProxyConfig().ngrok, DEFAULT_NGROK) + self.assertIsNone(ProxyConfig().ngrok_url) self.assertEqual( ProxyConfig().collapsible_reasoning, DEFAULT_COLLAPSIBLE_REASONING, @@ -136,6 +137,7 @@ def test_loads_config_from_user_yaml_file(self) -> None: "missing_reasoning_strategy: reject", "reasoning_cache_max_age_seconds: 60", "reasoning_cache_max_rows: 50", + "ngrok_url: https://example.ngrok.dev", ] ), encoding="utf-8", @@ -160,6 +162,7 @@ def test_loads_config_from_user_yaml_file(self) -> None: self.assertEqual(config.missing_reasoning_strategy, "reject") self.assertEqual(config.reasoning_cache_max_age_seconds, 60) self.assertEqual(config.reasoning_cache_max_rows, 50) + self.assertEqual(config.ngrok_url, "https://example.ngrok.dev") def test_invalid_config_values_fall_back_to_defaults(self) -> None: with TemporaryDirectory() as temp_dir: @@ -191,6 +194,13 @@ def test_invalid_config_values_fall_back_to_defaults(self) -> None: DEFAULT_COLLAPSIBLE_REASONING, ) + def test_ngrok_url_empty_or_whitespace_is_none(self) -> None: + with TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + config_path.write_text('ngrok_url: " "\n', encoding="utf-8") + config = ProxyConfig.from_file(config_path=config_path) + self.assertIsNone(config.ngrok_url) + def test_relative_reasoning_content_path_in_config_is_relative_to_config_file( self, ) -> None: diff --git a/tests/test_server.py b/tests/test_server.py index b898aa8..db89f0f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -140,6 +140,12 @@ def test_cli_boolean_flags_have_on_and_off_forms(self) -> None: self.assertTrue(args.cors) self.assertEqual(args.trace_dir, Path("/tmp/dcp-traces")) + def test_cli_accepts_ngrok_url(self) -> None: + args = build_arg_parser().parse_args( + ["--ngrok-url", "https://example.ngrok.app"] + ) + self.assertEqual(args.ngrok_url, "https://example.ngrok.app") + def test_default_console_logging_hides_info_prefix_and_timestamp(self) -> None: formatter = ConsoleLogFormatter(verbose=False) info_record = logging.LogRecord( diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py index 346a74f..ef09402 100644 --- a/tests/test_tunnel.py +++ b/tests/test_tunnel.py @@ -1,8 +1,10 @@ from __future__ import annotations import unittest +from unittest.mock import MagicMock, patch from deepseek_cursor_proxy.tunnel import ( + NgrokTunnel, local_tunnel_target, ngrok_agent_urls, parse_ngrok_public_url, @@ -49,6 +51,34 @@ def test_ngrok_agent_urls_use_current_api_then_legacy_fallback(self) -> None: ], ) + def test_ngrok_tunnel_appends_url_flag_when_configured(self) -> None: + with patch( + "deepseek_cursor_proxy.tunnel.shutil.which", return_value="/x/ngrok" + ): + with patch("deepseek_cursor_proxy.tunnel.subprocess.Popen") as popen: + popen.return_value = MagicMock(poll=lambda: None) + with patch.object( + NgrokTunnel, + "wait_for_public_url", + return_value="https://example.ngrok-free.app", + ): + tunnel = NgrokTunnel( + "http://127.0.0.1:9000", + ngrok_url="https://my.ngrok.dev", + ) + tunnel.start() + popen.assert_called_once() + argv, _kwargs = popen.call_args + self.assertEqual( + argv[0], + [ + "ngrok", + "http", + "http://127.0.0.1:9000", + "--url=https://my.ngrok.dev", + ], + ) + if __name__ == "__main__": unittest.main()