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.
|
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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue