feat(ngrok): support fixed endpoint URL (#39)
parent
a35583a3e0
commit
ea3da01417
14
README.md
14
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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 @@ class ProxyConfig:
|
|||
setting_value(settings, "ngrok"),
|
||||
DEFAULT_NGROK,
|
||||
),
|
||||
ngrok_url=as_optional_str(setting_value(settings, "ngrok_url")),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 @@ class NgrokTunnel:
|
|||
"`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.command, "http", self.target_url],
|
||||
argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class ConfigTests(unittest.TestCase):
|
|||
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 @@ class ConfigTests(unittest.TestCase):
|
|||
"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 @@ class ConfigTests(unittest.TestCase):
|
|||
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 @@ class ConfigTests(unittest.TestCase):
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@ class CliAndHelperTests(unittest.TestCase):
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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 @@ 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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in New Issue