feat(ngrok): support fixed endpoint URL (#39)

main
Rıdvan Altun 2026-05-10 22:29:42 +07:00 committed by GitHub
parent a35583a3e0
commit ea3da01417
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 87 additions and 2 deletions

View File

@ -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. 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: On the first run, `deepseek-cursor-proxy` will create:
- `~/.deepseek-cursor-proxy/config.yaml`: the configuration file - `~/.deepseek-cursor-proxy/config.yaml`: the configuration file
@ -99,6 +110,9 @@ deepseek-cursor-proxy --verbose
# Run without ngrok (run on localhost directly) # Run without ngrok (run on localhost directly)
deepseek-cursor-proxy --no-ngrok 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 # Use a different local port
deepseek-cursor-proxy --port 9000 deepseek-cursor-proxy --port 9000
``` ```

View File

@ -119,6 +119,13 @@ def as_str(value: Any, default: str) -> str:
return str(value) 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: def as_bool(value: Any, default: bool) -> bool:
if value is MISSING or value is None: if value is MISSING or value is None:
return default return default
@ -203,6 +210,7 @@ class ProxyConfig:
cors: bool = DEFAULT_CORS cors: bool = DEFAULT_CORS
verbose: bool = DEFAULT_VERBOSE verbose: bool = DEFAULT_VERBOSE
ngrok: bool = DEFAULT_NGROK ngrok: bool = DEFAULT_NGROK
ngrok_url: str | None = None
trace_dir: Path | None = None trace_dir: Path | None = None
@classmethod @classmethod
@ -283,4 +291,5 @@ class ProxyConfig:
setting_value(settings, "ngrok"), setting_value(settings, "ngrok"),
DEFAULT_NGROK, DEFAULT_NGROK,
), ),
ngrok_url=as_optional_str(setting_value(settings, "ngrok_url")),
) )

View File

@ -892,6 +892,14 @@ def build_arg_parser() -> argparse.ArgumentParser:
default=None, default=None,
help="Start an ngrok tunnel and print the Cursor base URL", 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( parser.add_argument(
"--verbose", "--verbose",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,
@ -1260,6 +1268,9 @@ def main(argv: list[str] | None = None) -> int:
updates["reasoning_content_path"] = args.reasoning_content_path updates["reasoning_content_path"] = args.reasoning_content_path
if args.ngrok is not None: if args.ngrok is not None:
updates["ngrok"] = args.ngrok 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: if args.verbose is not None:
updates["verbose"] = args.verbose updates["verbose"] = args.verbose
if args.trace_dir is not None: if args.trace_dir is not None:
@ -1314,7 +1325,7 @@ def main(argv: list[str] | None = None) -> int:
public_url: str | None = None public_url: str | None = None
if config.ngrok: if config.ngrok:
target_url = local_tunnel_target(config.host, config.port) target_url = local_tunnel_target(config.host, config.port)
tunnel = NgrokTunnel(target_url) tunnel = NgrokTunnel(target_url, ngrok_url=config.ngrok_url)
try: try:
public_url = tunnel.start() public_url = tunnel.start()
except RuntimeError as exc: except RuntimeError as exc:

View File

@ -57,6 +57,7 @@ def ngrok_agent_urls(api_url: str) -> list[str]:
@dataclass @dataclass
class NgrokTunnel: class NgrokTunnel:
target_url: str target_url: str
ngrok_url: str | None = None
command: str = "ngrok" command: str = "ngrok"
api_url: str = DEFAULT_NGROK_API_URL api_url: str = DEFAULT_NGROK_API_URL
startup_timeout: float = 15.0 startup_timeout: float = 15.0
@ -70,8 +71,12 @@ class NgrokTunnel:
"`ngrok config add-authtoken <token>` once." "`ngrok config add-authtoken <token>` once."
) )
argv = [self.command, "http", self.target_url]
if self.ngrok_url:
argv.append(f"--url={self.ngrok_url}")
self.process = subprocess.Popen( self.process = subprocess.Popen(
[self.command, "http", self.target_url], argv,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )

View File

@ -40,6 +40,7 @@ class ConfigTests(unittest.TestCase):
home / ".deepseek-cursor-proxy" / "reasoning_content.sqlite3", home / ".deepseek-cursor-proxy" / "reasoning_content.sqlite3",
) )
self.assertEqual(ProxyConfig().ngrok, DEFAULT_NGROK) self.assertEqual(ProxyConfig().ngrok, DEFAULT_NGROK)
self.assertIsNone(ProxyConfig().ngrok_url)
self.assertEqual( self.assertEqual(
ProxyConfig().collapsible_reasoning, ProxyConfig().collapsible_reasoning,
DEFAULT_COLLAPSIBLE_REASONING, DEFAULT_COLLAPSIBLE_REASONING,
@ -136,6 +137,7 @@ class ConfigTests(unittest.TestCase):
"missing_reasoning_strategy: reject", "missing_reasoning_strategy: reject",
"reasoning_cache_max_age_seconds: 60", "reasoning_cache_max_age_seconds: 60",
"reasoning_cache_max_rows: 50", "reasoning_cache_max_rows: 50",
"ngrok_url: https://example.ngrok.dev",
] ]
), ),
encoding="utf-8", encoding="utf-8",
@ -160,6 +162,7 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(config.missing_reasoning_strategy, "reject") self.assertEqual(config.missing_reasoning_strategy, "reject")
self.assertEqual(config.reasoning_cache_max_age_seconds, 60) self.assertEqual(config.reasoning_cache_max_age_seconds, 60)
self.assertEqual(config.reasoning_cache_max_rows, 50) 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: def test_invalid_config_values_fall_back_to_defaults(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
@ -191,6 +194,13 @@ class ConfigTests(unittest.TestCase):
DEFAULT_COLLAPSIBLE_REASONING, 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( def test_relative_reasoning_content_path_in_config_is_relative_to_config_file(
self, self,
) -> None: ) -> None:

View File

@ -140,6 +140,12 @@ class CliAndHelperTests(unittest.TestCase):
self.assertTrue(args.cors) self.assertTrue(args.cors)
self.assertEqual(args.trace_dir, Path("/tmp/dcp-traces")) 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: def test_default_console_logging_hides_info_prefix_and_timestamp(self) -> None:
formatter = ConsoleLogFormatter(verbose=False) formatter = ConsoleLogFormatter(verbose=False)
info_record = logging.LogRecord( info_record = logging.LogRecord(

View File

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import MagicMock, patch
from deepseek_cursor_proxy.tunnel import ( from deepseek_cursor_proxy.tunnel import (
NgrokTunnel,
local_tunnel_target, local_tunnel_target,
ngrok_agent_urls, ngrok_agent_urls,
parse_ngrok_public_url, parse_ngrok_public_url,
@ -49,6 +51,34 @@ class TunnelTests(unittest.TestCase):
], ],
) )
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__": if __name__ == "__main__":
unittest.main() unittest.main()