devlinker 1.4.1__tar.gz → 1.4.3__tar.gz

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.
Files changed (33) hide show
  1. {devlinker-1.4.1 → devlinker-1.4.3}/MANIFEST.in +0 -1
  2. {devlinker-1.4.1/devlinker.egg-info → devlinker-1.4.3}/PKG-INFO +116 -3
  3. {devlinker-1.4.1 → devlinker-1.4.3}/README.md +115 -2
  4. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/main.py +25 -6
  5. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/proxy.py +204 -16
  6. devlinker-1.4.3/devlinker/share.py +64 -0
  7. {devlinker-1.4.1 → devlinker-1.4.3/devlinker.egg-info}/PKG-INFO +116 -3
  8. {devlinker-1.4.1 → devlinker-1.4.3}/pyproject.toml +1 -1
  9. devlinker-1.4.1/devlinker/share.py +0 -32
  10. {devlinker-1.4.1 → devlinker-1.4.3}/LICENSE +0 -0
  11. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/__init__.py +0 -0
  12. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/config.py +0 -0
  13. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/detection_state.py +0 -0
  14. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/detector.py +0 -0
  15. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/detector_ai.py +0 -0
  16. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/devlinker_loader_instant.html +0 -0
  17. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/devlinker_loader_snippet.html +0 -0
  18. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/doctor.py +0 -0
  19. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/fix.py +0 -0
  20. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/fixer.py +0 -0
  21. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/global_state.py +0 -0
  22. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/inspect.py +0 -0
  23. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/logger.py +0 -0
  24. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/monitor.py +0 -0
  25. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/runner.py +0 -0
  26. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/tunnel.py +0 -0
  27. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/SOURCES.txt +0 -0
  28. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/dependency_links.txt +0 -0
  29. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/entry_points.txt +0 -0
  30. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/requires.txt +0 -0
  31. {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/top_level.txt +0 -0
  32. {devlinker-1.4.1 → devlinker-1.4.3}/setup.cfg +0 -0
  33. {devlinker-1.4.1 → devlinker-1.4.3}/setup.py +0 -0
@@ -1,3 +1,2 @@
1
1
  include devlinker/devlinker_loader_instant.html
2
- include devlinker/devlinker_loader_minimal.html
3
2
  include devlinker/devlinker_loader_snippet.html
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: A lightweight proxy that combines your frontend and backend into one link for easy development and sharing.
5
5
  Author-email: Mani <mani1028@users.noreply.github.com>
6
6
  Requires-Python: >=3.7
@@ -22,6 +22,76 @@ Dynamic: license-file
22
22
 
23
23
  Dev Linker starts your local development stack and routes frontend and backend traffic through one proxy URL, with optional LAN and public sharing.
24
24
 
25
+ ## ⚡ Quick Start (2 Minutes)
26
+
27
+ Install:
28
+
29
+ ```bash
30
+ pip install devlinker
31
+ ```
32
+
33
+ Run your apps:
34
+
35
+ ```bash
36
+ # Backend (example)
37
+ uvicorn main:app --reload
38
+
39
+ # Frontend (example)
40
+ npm run dev
41
+ ```
42
+
43
+ Run DevLinker:
44
+
45
+ ```bash
46
+ devlinker
47
+ ```
48
+
49
+ Open:
50
+
51
+ ```text
52
+ http://localhost:8001
53
+ ```
54
+
55
+ Done ✅
56
+
57
+ ## 🧠 How DevLinker Works
58
+
59
+ Request flow:
60
+
61
+ ```text
62
+ Browser -> DevLinker Proxy -> Frontend or Backend
63
+ ```
64
+
65
+ Routing rules:
66
+
67
+ - / routes to frontend (React/Vite)
68
+ - /api/* routes to backend (FastAPI/Flask/Node)
69
+
70
+ DevLinker acts as a smart proxy and optional tunnel layer for local, LAN, and public development links.
71
+
72
+ Architecture diagram:
73
+
74
+ ```mermaid
75
+ flowchart LR
76
+ B[Browser / Mobile] --> P[DevLinker Proxy]
77
+ P --> F[Frontend Dev Server\nVite/React]
78
+ P --> A[Backend API\nFastAPI/Flask/Node]
79
+ P --> T[Optional Tunnel\nCloudflare/ngrok]
80
+ ```
81
+
82
+ ## 🎯 Use Cases
83
+
84
+ - Test APIs and UI flows on mobile devices over WLAN
85
+ - Share local work instantly with teammates using one public URL
86
+ - Debug frontend-backend integration from a single entrypoint
87
+ - Reduce CORS/preflight issues during development
88
+
89
+ ## 🖼️ Demo & Screenshots
90
+
91
+ - Terminal startup output: add screenshot at docs/images/terminal-startup.png
92
+ - Browser app via proxy: add screenshot at docs/images/browser-proxy.png
93
+ - Public URL share demo: add screenshot at docs/images/public-url.png
94
+
25
95
 
26
96
  ## Features
27
97
 
@@ -36,6 +106,8 @@ Dev Linker starts your local development stack and routes frontend and backend t
36
106
  - 🌍 **Public Sharing:** Share your local dev environment instantly with `--url` (startup) or `devlinker share` (runtime, no restart).
37
107
  - 🔄 **Dynamic Tunnel Control:** `devlinker unshare` disables public tunnel at runtime.
38
108
  - 📡 **WLAN Sharing:** Prints LAN URL for same-network device access.
109
+ - 🔒 **Secure Token Linking:** Optional token gate for LAN/public access with `DEVLINKER_LINK_TOKEN`.
110
+ - 📊 **Browser API Logs Dashboard:** Open `/__devlinker/dashboard` for lightweight live API visibility.
39
111
  - 🧑‍💻 **Interactive CLI:** Modern, colorized, emoji-rich terminal UX for all commands.
40
112
  - 🧩 **Zero Config:** Works out-of-the-box for most FastAPI, Flask, Vite, and Docker projects.
41
113
  - 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
@@ -58,6 +130,7 @@ If DevLinker helps you ship faster, consider supporting the project:
58
130
  - `devlinker support` — Show UPI support QR code in terminal
59
131
  - `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
60
132
  - `devlinker share` — Enable public tunnel at runtime (no restart)
133
+ - `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
61
134
  - `devlinker unshare` — Disable public tunnel at runtime
62
135
  - `devlinker doctor` — Diagnose issues, see categorized problems and fixes
63
136
  - `devlinker fix` — Auto-fix common issues (env, API paths, config)
@@ -70,6 +143,30 @@ If DevLinker helps you ship faster, consider supporting the project:
70
143
  - `devlinker --debug` — Enable debug mode (turns on live API request logger)
71
144
  - `devlinker --version` — Show version
72
145
 
146
+ Security token (optional):
147
+
148
+ ```bash
149
+ set DEVLINKER_LINK_TOKEN=your-secret-token
150
+ devlinker --url
151
+ ```
152
+
153
+ When enabled, LAN/public requests must include one of:
154
+ - query param `dl_token=...`
155
+ - header `X-DevLinker-Token: ...`
156
+ - header `Authorization: Bearer ...`
157
+
158
+ Built-in API logs dashboard:
159
+
160
+ ```text
161
+ http://localhost:<proxy-port>/__devlinker/dashboard
162
+ ```
163
+
164
+ JSON stream endpoint used by the dashboard:
165
+
166
+ ```text
167
+ http://localhost:<proxy-port>/__devlinker/logs
168
+ ```
169
+
73
170
  ## Project Structure
74
171
 
75
172
  ```text
@@ -182,14 +279,16 @@ devlinker --docker
182
279
 
183
280
  ## Tunnel and Sharing Modes
184
281
 
185
- By default, DevLinker starts **fast local proxy only** (no tunnel). To enable a public tunnel, use the `--url` flag:
282
+ By default, DevLinker starts **fast local proxy only** (no tunnel). It prints a LAN URL when it can detect a local network interface, and you can share that link with devices on the same Wi-Fi/LAN.
283
+
284
+ For access from another network, start with a public tunnel using the `--url` flag:
186
285
 
187
286
 
188
287
  ```bash
189
288
  devlinker --url
190
289
  ```
191
290
 
192
- This will start the proxy and open a public tunnel (Cloudflare or ngrok). The output will show:
291
+ This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
193
292
 
194
293
  ```text
195
294
  🌍 Enabling public tunnel...
@@ -200,6 +299,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
200
299
  ℹ Share this link with collaborators.
201
300
  ```
202
301
 
302
+ If you already started DevLinker and want to turn on sharing without restarting, use:
303
+
304
+ ```bash
305
+ devlinker share
306
+ ```
307
+
308
+ If you use a custom proxy port, pass it explicitly:
309
+
310
+ ```bash
311
+ devlinker share --proxy-port 18000
312
+ ```
313
+
314
+ If your friend is on the same Wi-Fi/LAN, use the printed LAN URL like `http://192.168.x.x:<proxy-port>`. If they are outside your network, use the public tunnel URL instead.
315
+
203
316
  To force tunnel off (even if --url is passed):
204
317
 
205
318
  ```bash
@@ -2,6 +2,76 @@
2
2
 
3
3
  Dev Linker starts your local development stack and routes frontend and backend traffic through one proxy URL, with optional LAN and public sharing.
4
4
 
5
+ ## ⚡ Quick Start (2 Minutes)
6
+
7
+ Install:
8
+
9
+ ```bash
10
+ pip install devlinker
11
+ ```
12
+
13
+ Run your apps:
14
+
15
+ ```bash
16
+ # Backend (example)
17
+ uvicorn main:app --reload
18
+
19
+ # Frontend (example)
20
+ npm run dev
21
+ ```
22
+
23
+ Run DevLinker:
24
+
25
+ ```bash
26
+ devlinker
27
+ ```
28
+
29
+ Open:
30
+
31
+ ```text
32
+ http://localhost:8001
33
+ ```
34
+
35
+ Done ✅
36
+
37
+ ## 🧠 How DevLinker Works
38
+
39
+ Request flow:
40
+
41
+ ```text
42
+ Browser -> DevLinker Proxy -> Frontend or Backend
43
+ ```
44
+
45
+ Routing rules:
46
+
47
+ - / routes to frontend (React/Vite)
48
+ - /api/* routes to backend (FastAPI/Flask/Node)
49
+
50
+ DevLinker acts as a smart proxy and optional tunnel layer for local, LAN, and public development links.
51
+
52
+ Architecture diagram:
53
+
54
+ ```mermaid
55
+ flowchart LR
56
+ B[Browser / Mobile] --> P[DevLinker Proxy]
57
+ P --> F[Frontend Dev Server\nVite/React]
58
+ P --> A[Backend API\nFastAPI/Flask/Node]
59
+ P --> T[Optional Tunnel\nCloudflare/ngrok]
60
+ ```
61
+
62
+ ## 🎯 Use Cases
63
+
64
+ - Test APIs and UI flows on mobile devices over WLAN
65
+ - Share local work instantly with teammates using one public URL
66
+ - Debug frontend-backend integration from a single entrypoint
67
+ - Reduce CORS/preflight issues during development
68
+
69
+ ## 🖼️ Demo & Screenshots
70
+
71
+ - Terminal startup output: add screenshot at docs/images/terminal-startup.png
72
+ - Browser app via proxy: add screenshot at docs/images/browser-proxy.png
73
+ - Public URL share demo: add screenshot at docs/images/public-url.png
74
+
5
75
 
6
76
  ## Features
7
77
 
@@ -16,6 +86,8 @@ Dev Linker starts your local development stack and routes frontend and backend t
16
86
  - 🌍 **Public Sharing:** Share your local dev environment instantly with `--url` (startup) or `devlinker share` (runtime, no restart).
17
87
  - 🔄 **Dynamic Tunnel Control:** `devlinker unshare` disables public tunnel at runtime.
18
88
  - 📡 **WLAN Sharing:** Prints LAN URL for same-network device access.
89
+ - 🔒 **Secure Token Linking:** Optional token gate for LAN/public access with `DEVLINKER_LINK_TOKEN`.
90
+ - 📊 **Browser API Logs Dashboard:** Open `/__devlinker/dashboard` for lightweight live API visibility.
19
91
  - 🧑‍💻 **Interactive CLI:** Modern, colorized, emoji-rich terminal UX for all commands.
20
92
  - 🧩 **Zero Config:** Works out-of-the-box for most FastAPI, Flask, Vite, and Docker projects.
21
93
  - 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
@@ -38,6 +110,7 @@ If DevLinker helps you ship faster, consider supporting the project:
38
110
  - `devlinker support` — Show UPI support QR code in terminal
39
111
  - `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
40
112
  - `devlinker share` — Enable public tunnel at runtime (no restart)
113
+ - `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
41
114
  - `devlinker unshare` — Disable public tunnel at runtime
42
115
  - `devlinker doctor` — Diagnose issues, see categorized problems and fixes
43
116
  - `devlinker fix` — Auto-fix common issues (env, API paths, config)
@@ -50,6 +123,30 @@ If DevLinker helps you ship faster, consider supporting the project:
50
123
  - `devlinker --debug` — Enable debug mode (turns on live API request logger)
51
124
  - `devlinker --version` — Show version
52
125
 
126
+ Security token (optional):
127
+
128
+ ```bash
129
+ set DEVLINKER_LINK_TOKEN=your-secret-token
130
+ devlinker --url
131
+ ```
132
+
133
+ When enabled, LAN/public requests must include one of:
134
+ - query param `dl_token=...`
135
+ - header `X-DevLinker-Token: ...`
136
+ - header `Authorization: Bearer ...`
137
+
138
+ Built-in API logs dashboard:
139
+
140
+ ```text
141
+ http://localhost:<proxy-port>/__devlinker/dashboard
142
+ ```
143
+
144
+ JSON stream endpoint used by the dashboard:
145
+
146
+ ```text
147
+ http://localhost:<proxy-port>/__devlinker/logs
148
+ ```
149
+
53
150
  ## Project Structure
54
151
 
55
152
  ```text
@@ -162,14 +259,16 @@ devlinker --docker
162
259
 
163
260
  ## Tunnel and Sharing Modes
164
261
 
165
- By default, DevLinker starts **fast local proxy only** (no tunnel). To enable a public tunnel, use the `--url` flag:
262
+ By default, DevLinker starts **fast local proxy only** (no tunnel). It prints a LAN URL when it can detect a local network interface, and you can share that link with devices on the same Wi-Fi/LAN.
263
+
264
+ For access from another network, start with a public tunnel using the `--url` flag:
166
265
 
167
266
 
168
267
  ```bash
169
268
  devlinker --url
170
269
  ```
171
270
 
172
- This will start the proxy and open a public tunnel (Cloudflare or ngrok). The output will show:
271
+ This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
173
272
 
174
273
  ```text
175
274
  🌍 Enabling public tunnel...
@@ -180,6 +279,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
180
279
  ℹ Share this link with collaborators.
181
280
  ```
182
281
 
282
+ If you already started DevLinker and want to turn on sharing without restarting, use:
283
+
284
+ ```bash
285
+ devlinker share
286
+ ```
287
+
288
+ If you use a custom proxy port, pass it explicitly:
289
+
290
+ ```bash
291
+ devlinker share --proxy-port 18000
292
+ ```
293
+
294
+ If your friend is on the same Wi-Fi/LAN, use the printed LAN URL like `http://192.168.x.x:<proxy-port>`. If they are outside your network, use the public tunnel URL instead.
295
+
183
296
  To force tunnel off (even if --url is passed):
184
297
 
185
298
  ```bash
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import socket
4
5
  import sys
5
6
  import time
@@ -32,7 +33,7 @@ except ImportError: # pragma: no cover - fallback when rich is unavailable
32
33
 
33
34
  from . import __version__
34
35
  from .detector import check_port, detect_ports, is_vite_port
35
- from .proxy import start_proxy
36
+ from .proxy import start_proxy, wait_for_proxy_startup
36
37
  from .runner import detect_backend_port, start_servers
37
38
  from .tunnel import start_tunnel
38
39
  from .doctor import doctor
@@ -42,6 +43,7 @@ from .share import share, unshare
42
43
  from .config import load_config
43
44
  from .inspect import inspect
44
45
  from .monitor import monitor
46
+ from .global_state import STATE
45
47
 
46
48
  SUPPORT_UPI_ID = "devlinker@upi"
47
49
  SUPPORT_UPI_LINK = "upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀"
@@ -193,6 +195,18 @@ def _with_ngrok_skip_warning(url: str) -> str:
193
195
  return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
194
196
 
195
197
 
198
+ def _with_link_token(url: str) -> str:
199
+ token = os.getenv("DEVLINKER_LINK_TOKEN", "").strip()
200
+ if not token:
201
+ return url
202
+
203
+ parts = urlsplit(url)
204
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
205
+ query["dl_token"] = token
206
+ new_query = urlencode(query)
207
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
208
+
209
+
196
210
 
197
211
  def _print_summary(
198
212
  frontend_port: int | None,
@@ -589,6 +603,7 @@ def _run_proxy(
589
603
  )
590
604
 
591
605
  proxy_port = _select_proxy_port(proxy_port)
606
+ STATE["proxy_port"] = proxy_port
592
607
  _write_frontend_api_env(proxy_port)
593
608
 
594
609
  if not live_status:
@@ -606,8 +621,10 @@ def _run_proxy(
606
621
  enable_debug_logs=debug,
607
622
  )
608
623
 
609
- # Allow proxy thread to bind before opening tunnel.
610
- time.sleep(1)
624
+ if not wait_for_proxy_startup(timeout=5.0):
625
+ raise click.ClickException(
626
+ f"Proxy failed to start on port {proxy_port}. Check whether the port is already in use."
627
+ )
611
628
 
612
629
  if live_status:
613
630
  live_status.update("Proxy", f"✔ Active ({proxy_port})", style="green")
@@ -616,12 +633,14 @@ def _run_proxy(
616
633
  if lan_enabled:
617
634
  local_ips = _get_local_ips()
618
635
  if local_ips:
619
- wlan_url = f"http://{local_ips[0]}:{proxy_port}"
636
+ wlan_url = _with_link_token(f"http://{local_ips[0]}:{proxy_port}")
620
637
  _ui_status("✔", f"LAN share: {wlan_url}", style="green")
621
638
  if len(local_ips) > 1:
622
- alternative_urls = ", ".join(f"http://{ip}:{proxy_port}" for ip in local_ips[1:])
639
+ alternative_urls = ", ".join(_with_link_token(f"http://{ip}:{proxy_port}") for ip in local_ips[1:])
623
640
  _ui_status("ℹ", f"Alternate LAN URLs: {alternative_urls}", style="blue")
624
641
  _ui_status("ℹ", "Share with teammates on the same WiFi/LAN.", style="blue")
642
+ if os.getenv("DEVLINKER_LINK_TOKEN", "").strip():
643
+ _ui_status("🔒", "Token protection is ON for LAN/public traffic.", style="green")
625
644
  _ui_status(
626
645
  "⚠",
627
646
  "Camera/mic may be blocked on HTTP. Use localhost or --url for HTTPS.",
@@ -653,7 +672,7 @@ def _run_proxy(
653
672
  try:
654
673
  _ui_status("🌍", "Enabling public tunnel...", style="green")
655
674
  provider, public_url = start_tunnel(proxy_port)
656
- warning_free_url = _with_ngrok_skip_warning(public_url)
675
+ warning_free_url = _with_link_token(_with_ngrok_skip_warning(public_url))
657
676
  provider_label = "Cloudflare" if provider == "cloudflare" else "ngrok"
658
677
  _ui_status("✔", f"Tunnel provider: {provider_label}", style="blue")
659
678
  _ui_status("✔", f"Public URL: {warning_free_url}", style="cyan")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import os
4
5
  import time
5
6
  from typing import Dict, Optional
6
7
  from urllib.parse import urlencode
@@ -9,7 +10,7 @@ import httpx
9
10
  import uvicorn
10
11
  import websockets
11
12
  from fastapi import FastAPI, Request, Response, WebSocket
12
- from fastapi.responses import PlainTextResponse
13
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
13
14
  from starlette.websockets import WebSocketDisconnect
14
15
  from websockets.exceptions import ConnectionClosed
15
16
 
@@ -24,6 +25,8 @@ _recent_lock = threading.Lock()
24
25
  _printed_fixes = set()
25
26
  _printed_live_header = False
26
27
  LIVE_REQUEST_LOGGING_ENABLED = False
28
+ MAX_RECENT_REQUESTS = 200
29
+ PROXY_READY_EVENT = threading.Event()
27
30
 
28
31
 
29
32
  def _format_request_context(path: str, method: str | None, status: int, target: str) -> str:
@@ -54,8 +57,34 @@ def _print_live_request_line(method: str, path: str, status: int, elapsed_ms: fl
54
57
  _printed_live_header = True
55
58
  print(f"{method.upper():<6} {path:<24} {status:<3} {elapsed_ms:.0f}ms")
56
59
 
60
+
61
+ def _configured_link_token() -> str | None:
62
+ token = os.getenv("DEVLINKER_LINK_TOKEN", "").strip()
63
+ return token or None
64
+
65
+
66
+ def _extract_presented_token(headers: Dict[str, str], query_params) -> str | None:
67
+ query_token = query_params.get("dl_token")
68
+ if query_token:
69
+ return query_token
70
+ direct_header = headers.get("x-devlinker-token", "").strip()
71
+ if direct_header:
72
+ return direct_header
73
+ auth_header = headers.get("authorization", "").strip()
74
+ bearer_prefix = "Bearer "
75
+ if auth_header.startswith(bearer_prefix):
76
+ return auth_header[len(bearer_prefix):].strip()
77
+ return None
78
+
79
+
80
+ def _is_link_token_valid(expected_token: str | None, headers: Dict[str, str], query_params) -> bool:
81
+ if not expected_token:
82
+ return True
83
+ presented = _extract_presented_token(headers, query_params)
84
+ return bool(presented) and presented == expected_token
85
+
57
86
  class RequestInspector:
58
- def analyze(self, path, status, target, method=None, response_text=None):
87
+ def analyze(self, path, status, target, method=None, response_text=None, elapsed_ms=None):
59
88
  warnings = []
60
89
  normalized_method = method.upper() if method else ""
61
90
  is_root_document_request = path == "/" and normalized_method in {"GET", "HEAD", "OPTIONS"}
@@ -87,14 +116,24 @@ class RequestInspector:
87
116
  warnings.append(issue)
88
117
  # Log request for inspector
89
118
  with _recent_lock:
90
- _recent_requests.append({"path": path, "status": status, "target": target})
91
- if len(_recent_requests) > 50:
119
+ _recent_requests.append(
120
+ {
121
+ "ts": int(time.time() * 1000),
122
+ "method": normalized_method or "GET",
123
+ "path": path,
124
+ "status": status,
125
+ "target": target,
126
+ "latency_ms": round(float(elapsed_ms), 1) if elapsed_ms is not None else None,
127
+ }
128
+ )
129
+ if len(_recent_requests) > MAX_RECENT_REQUESTS:
92
130
  _recent_requests.pop(0)
93
131
  return warnings
94
132
 
95
133
  FRONTEND: Optional[int] = None
96
134
  BACKEND: Optional[int] = None
97
135
  HTTP_CLIENT: Optional[httpx.AsyncClient] = None
136
+ UPSTREAM_HOST_CANDIDATES: tuple[str, ...] = ("127.0.0.1", "localhost", "::1")
98
137
 
99
138
  HOP_BY_HOP_HEADERS = {
100
139
  "connection",
@@ -137,6 +176,7 @@ def _apply_cors_headers(headers: Dict[str, str], request: Request) -> Dict[str,
137
176
  async def _on_startup() -> None:
138
177
  global HTTP_CLIENT
139
178
  HTTP_CLIENT = httpx.AsyncClient(timeout=15.0, follow_redirects=False)
179
+ PROXY_READY_EVENT.set()
140
180
 
141
181
 
142
182
  @app.on_event("shutdown")
@@ -145,6 +185,7 @@ async def _on_shutdown() -> None:
145
185
  if HTTP_CLIENT is not None:
146
186
  await HTTP_CLIENT.aclose()
147
187
  HTTP_CLIENT = None
188
+ PROXY_READY_EVENT.clear()
148
189
 
149
190
 
150
191
  def _connection_header_tokens(headers: Dict[str, str]) -> set[str]:
@@ -188,22 +229,38 @@ def _target_port(path: str) -> Optional[int]:
188
229
  return FRONTEND
189
230
 
190
231
 
191
- def _build_target_http_url(port: int, path: str, query_params: list[tuple[str, str]]) -> str:
232
+ def _format_host_for_url(host: str) -> str:
233
+ if ":" in host and not host.startswith("["):
234
+ return f"[{host}]"
235
+ return host
236
+
237
+
238
+ def _build_target_http_url(
239
+ port: int,
240
+ path: str,
241
+ query_params: list[tuple[str, str]],
242
+ host: str = "127.0.0.1",
243
+ ) -> str:
192
244
  query_string = urlencode(query_params, doseq=True)
193
- base_url = f"http://127.0.0.1:{port}{path}"
245
+ base_url = f"http://{_format_host_for_url(host)}:{port}{path}"
194
246
  if not query_string:
195
247
  return base_url
196
248
  return f"{base_url}?{query_string}"
197
249
 
198
250
 
199
- def _build_target_ws_url(port: int, path: str, query: str) -> str:
200
- base_url = f"ws://127.0.0.1:{port}{path}"
251
+ def _build_target_ws_url(port: int, path: str, query: str, host: str = "127.0.0.1") -> str:
252
+ base_url = f"ws://{_format_host_for_url(host)}:{port}{path}"
201
253
  if not query:
202
254
  return base_url
203
255
  return f"{base_url}?{query}"
204
256
 
205
257
 
206
258
  async def _forward_http(request: Request) -> Response:
259
+ if request.url.path == "/__devlinker/logs":
260
+ return await logs_dashboard_data()
261
+ if request.url.path == "/__devlinker/dashboard":
262
+ return await logs_dashboard_page()
263
+
207
264
  if request.method == "OPTIONS" and request.headers.get("access-control-request-method"):
208
265
  return Response(
209
266
  status_code=204,
@@ -267,6 +324,13 @@ async def _forward_http(request: Request) -> Response:
267
324
  is_lan = mode == "lan"
268
325
  is_public = mode == "public"
269
326
  is_secure = _is_secure_request(request, host_header)
327
+ required_link_token = _configured_link_token()
328
+ if (is_lan or is_public) and not _is_link_token_valid(required_link_token, dict(request.headers), request.query_params):
329
+ return PlainTextResponse(
330
+ "Unauthorized link: include dl_token query or X-DevLinker-Token header.",
331
+ status_code=401,
332
+ headers=_apply_cors_headers(_apply_security_headers({}), request),
333
+ )
270
334
  is_instant = request.headers.get("x-devlinker-instant") == "1"
271
335
  accept_header = request.headers.get("accept", "")
272
336
  sec_fetch_dest = request.headers.get("sec-fetch-dest", "")
@@ -338,16 +402,33 @@ async def _forward_http(request: Request) -> Response:
338
402
 
339
403
  payload = await request.body()
340
404
  query_params = list(request.query_params.multi_items())
341
- target_url = _build_target_http_url(target_port, request.url.path, query_params)
342
405
  started_at = time.perf_counter()
343
406
 
344
407
  try:
345
- upstream = await HTTP_CLIENT.request(
346
- method=request.method,
347
- url=target_url,
348
- content=payload,
349
- headers=_filter_request_headers(dict(request.headers)),
350
- )
408
+ upstream = None
409
+ last_exc: httpx.RequestError | None = None
410
+ for upstream_host in UPSTREAM_HOST_CANDIDATES:
411
+ target_url = _build_target_http_url(
412
+ target_port,
413
+ request.url.path,
414
+ query_params,
415
+ host=upstream_host,
416
+ )
417
+ try:
418
+ upstream = await HTTP_CLIENT.request(
419
+ method=request.method,
420
+ url=target_url,
421
+ content=payload,
422
+ headers=_filter_request_headers(dict(request.headers)),
423
+ )
424
+ break
425
+ except httpx.RequestError as exc:
426
+ last_exc = exc
427
+
428
+ if upstream is None and last_exc is not None:
429
+ raise last_exc
430
+ if upstream is None:
431
+ raise RuntimeError("Unexpected empty upstream response")
351
432
  except httpx.RequestError as exc:
352
433
  status = 502
353
434
  elapsed_ms = (time.perf_counter() - started_at) * 1000
@@ -376,7 +457,8 @@ async def _forward_http(request: Request) -> Response:
376
457
  upstream.status_code,
377
458
  target_name,
378
459
  method=request.method,
379
- response_text=upstream.text
460
+ response_text=upstream.text,
461
+ elapsed_ms=elapsed_ms,
380
462
  )
381
463
  context = _format_request_context(request.url.path, request.method, upstream.status_code, target_name)
382
464
  # Only print routing warnings for error responses or /api paths
@@ -435,6 +517,11 @@ async def _forward_http(request: Request) -> Response:
435
517
 
436
518
 
437
519
  async def _proxy_websocket(websocket: WebSocket) -> None:
520
+ required_link_token = _configured_link_token()
521
+ if required_link_token and not _is_link_token_valid(required_link_token, dict(websocket.headers), websocket.query_params):
522
+ await websocket.close(code=1008)
523
+ return
524
+
438
525
  target_port = _target_port(websocket.url.path)
439
526
  if target_port is None:
440
527
  await websocket.close(code=1013)
@@ -529,6 +616,102 @@ async def websocket_proxy(websocket: WebSocket, path: str) -> None: # noqa: ARG
529
616
  await _proxy_websocket(websocket)
530
617
 
531
618
 
619
+ @app.get("/__devlinker/logs")
620
+ async def logs_dashboard_data() -> JSONResponse:
621
+ with _recent_lock:
622
+ records = list(_recent_requests[-100:])
623
+ return JSONResponse({"count": len(records), "items": records})
624
+
625
+
626
+ @app.get("/__devlinker/dashboard")
627
+ async def logs_dashboard_page() -> HTMLResponse:
628
+ html = """<!doctype html>
629
+ <html>
630
+ <head>
631
+ <meta charset=\"utf-8\" />
632
+ <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />
633
+ <title>DevLinker API Logs</title>
634
+ <style>
635
+ :root { --bg:#f4f7fb; --card:#ffffff; --ink:#0f172a; --muted:#64748b; --ok:#065f46; --warn:#92400e; --err:#991b1b; --line:#dbe3ee; }
636
+ body { margin:0; font-family:\"Segoe UI\",\"Trebuchet MS\",sans-serif; background: radial-gradient(circle at top left,#e7f1ff,transparent 45%), var(--bg); color:var(--ink); }
637
+ .wrap { max-width: 1100px; margin: 28px auto; padding: 0 16px; }
638
+ .card { background: var(--card); border:1px solid var(--line); border-radius:14px; box-shadow: 0 10px 25px rgba(15,23,42,.06); overflow:hidden; }
639
+ h1 { margin:0; font-size: 1.4rem; }
640
+ .head { padding: 14px 16px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--line); }
641
+ .meta { color:var(--muted); font-size:.9rem; }
642
+ table { width:100%; border-collapse: collapse; }
643
+ th,td { padding:10px 12px; border-bottom:1px solid var(--line); text-align:left; font-size:.9rem; }
644
+ th { color:var(--muted); font-weight:600; }
645
+ .s2 { color: var(--ok); font-weight: 700; }
646
+ .s4 { color: var(--warn); font-weight: 700; }
647
+ .s5 { color: var(--err); font-weight: 700; }
648
+ .path { font-family:Consolas, monospace; }
649
+ .empty { padding: 20px; color: var(--muted); }
650
+ </style>
651
+ </head>
652
+ <body>
653
+ <div class=\"wrap\">
654
+ <div class=\"card\">
655
+ <div class=\"head\">
656
+ <h1>API Logs Dashboard</h1>
657
+ <div class=\"meta\" id=\"meta\">Waiting for traffic...</div>
658
+ </div>
659
+ <div id=\"content\" class=\"empty\">No requests yet.</div>
660
+ </div>
661
+ </div>
662
+ <script>
663
+ function statusClass(code){
664
+ if(code >= 500) return 's5';
665
+ if(code >= 400) return 's4';
666
+ return 's2';
667
+ }
668
+ function ago(ms){
669
+ const d = Date.now() - ms;
670
+ if (d < 1000) return 'now';
671
+ if (d < 60000) return Math.floor(d/1000) + 's ago';
672
+ return Math.floor(d/60000) + 'm ago';
673
+ }
674
+ function render(items){
675
+ const content = document.getElementById('content');
676
+ const meta = document.getElementById('meta');
677
+ if(!items.length){
678
+ content.className = 'empty';
679
+ content.textContent = 'No requests yet.';
680
+ meta.textContent = 'Waiting for traffic...';
681
+ return;
682
+ }
683
+ const rows = items.slice().reverse().map(item => {
684
+ const status = Number(item.status || 0);
685
+ const lat = item.latency_ms == null ? '-' : item.latency_ms + 'ms';
686
+ return '<tr>' +
687
+ '<td>' + (item.method || '-') + '</td>' +
688
+ '<td class="path">' + (item.path || '-') + '</td>' +
689
+ '<td><span class="' + statusClass(status) + '">' + status + '</span></td>' +
690
+ '<td>' + (item.target || '-') + '</td>' +
691
+ '<td>' + lat + '</td>' +
692
+ '<td>' + (item.ts ? ago(item.ts) : '-') + '</td>' +
693
+ '</tr>';
694
+ }).join('');
695
+ content.className = '';
696
+ content.innerHTML = '<table><thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Target</th><th>Latency</th><th>When</th></tr></thead><tbody>' + rows + '</tbody></table>';
697
+ meta.textContent = items.length + ' requests captured';
698
+ }
699
+ async function tick(){
700
+ try{
701
+ const resp = await fetch('/__devlinker/logs', {cache:'no-store'});
702
+ const data = await resp.json();
703
+ render(Array.isArray(data.items) ? data.items : []);
704
+ }catch(_){
705
+ }
706
+ }
707
+ tick();
708
+ setInterval(tick, 1500);
709
+ </script>
710
+ </body>
711
+ </html>"""
712
+ return HTMLResponse(html)
713
+
714
+
532
715
  def start_proxy(
533
716
  frontend_port: Optional[int],
534
717
  backend_port: int,
@@ -540,9 +723,14 @@ def start_proxy(
540
723
  BACKEND = backend_port
541
724
  LIVE_REQUEST_LOGGING_ENABLED = enable_debug_logs
542
725
  _printed_live_header = False
726
+ PROXY_READY_EVENT.clear()
543
727
 
544
728
  thread = threading.Thread(
545
729
  target=lambda: uvicorn.run(app, host="0.0.0.0", port=proxy_port, log_level="warning"),
546
730
  daemon=True,
547
731
  )
548
732
  thread.start()
733
+
734
+
735
+ def wait_for_proxy_startup(timeout: float = 5.0) -> bool:
736
+ return PROXY_READY_EVENT.wait(timeout)
@@ -0,0 +1,64 @@
1
+ import click
2
+ import requests
3
+
4
+ from devlinker.global_state import STATE
5
+ from devlinker.tunnel import start_tunnel
6
+
7
+
8
+ _COMMON_PROXY_PORTS = (8000, 8001, 8002, 8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010, 18000)
9
+
10
+
11
+ def _is_devlinker_proxy(port: int) -> bool:
12
+ try:
13
+ response = requests.get(f"http://127.0.0.1:{port}/__devlinker/dashboard", timeout=0.5)
14
+ except requests.RequestException:
15
+ return False
16
+ return response.status_code == 200 and "API Logs Dashboard" in response.text
17
+
18
+
19
+ def _resolve_proxy_port(requested_port: int | None) -> int:
20
+ if requested_port is not None:
21
+ if _is_devlinker_proxy(requested_port):
22
+ return requested_port
23
+ raise click.ClickException(
24
+ f"No DevLinker proxy is running on port {requested_port}. Start devlinker first, or pass the correct --proxy-port."
25
+ )
26
+
27
+ for candidate in _COMMON_PROXY_PORTS:
28
+ if _is_devlinker_proxy(candidate):
29
+ return candidate
30
+
31
+ raise click.ClickException(
32
+ "No running DevLinker proxy was found. Start devlinker first, or pass --proxy-port <port>."
33
+ )
34
+
35
+ @click.command()
36
+ @click.option("--proxy-port", type=int, default=None, help="Proxy port to tunnel. Auto-detect when omitted.")
37
+ def share(proxy_port: int | None):
38
+ """Enable public tunnel at runtime (no restart)."""
39
+ if STATE["tunnel"]:
40
+ click.secho("⚠️ Already shared", fg="yellow")
41
+ return
42
+ try:
43
+ resolved_proxy_port = _resolve_proxy_port(proxy_port)
44
+ STATE["proxy_port"] = resolved_proxy_port
45
+ provider, url = start_tunnel(resolved_proxy_port)
46
+ STATE["tunnel"] = url
47
+ click.secho("\n🌍 Public Sharing Enabled\n" + ("─" * 24), fg="green", bold=True)
48
+ click.secho("✔ Tunnel connected", fg="green")
49
+ click.secho(f"\nPublic URL:\n{url}\n", fg="cyan", bold=True)
50
+ click.secho("📤 Share this link with your team", fg="magenta")
51
+ except Exception as exc:
52
+ click.secho(f"[WARN] Tunnel failed: {exc}", fg="red")
53
+ click.secho("[INFO] Next step: install cloudflared or configure ngrok auth.", fg="yellow")
54
+
55
+ @click.command()
56
+ def unshare():
57
+ """Disable public tunnel at runtime (no restart)."""
58
+ if not STATE["tunnel"]:
59
+ click.secho("⚠️ No active tunnel", fg="yellow")
60
+ return
61
+ from devlinker.tunnel import stop_tunnel
62
+ stop_tunnel()
63
+ STATE["tunnel"] = None
64
+ click.secho("🛑 Sharing stopped", fg="red", bold=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: A lightweight proxy that combines your frontend and backend into one link for easy development and sharing.
5
5
  Author-email: Mani <mani1028@users.noreply.github.com>
6
6
  Requires-Python: >=3.7
@@ -22,6 +22,76 @@ Dynamic: license-file
22
22
 
23
23
  Dev Linker starts your local development stack and routes frontend and backend traffic through one proxy URL, with optional LAN and public sharing.
24
24
 
25
+ ## ⚡ Quick Start (2 Minutes)
26
+
27
+ Install:
28
+
29
+ ```bash
30
+ pip install devlinker
31
+ ```
32
+
33
+ Run your apps:
34
+
35
+ ```bash
36
+ # Backend (example)
37
+ uvicorn main:app --reload
38
+
39
+ # Frontend (example)
40
+ npm run dev
41
+ ```
42
+
43
+ Run DevLinker:
44
+
45
+ ```bash
46
+ devlinker
47
+ ```
48
+
49
+ Open:
50
+
51
+ ```text
52
+ http://localhost:8001
53
+ ```
54
+
55
+ Done ✅
56
+
57
+ ## 🧠 How DevLinker Works
58
+
59
+ Request flow:
60
+
61
+ ```text
62
+ Browser -> DevLinker Proxy -> Frontend or Backend
63
+ ```
64
+
65
+ Routing rules:
66
+
67
+ - / routes to frontend (React/Vite)
68
+ - /api/* routes to backend (FastAPI/Flask/Node)
69
+
70
+ DevLinker acts as a smart proxy and optional tunnel layer for local, LAN, and public development links.
71
+
72
+ Architecture diagram:
73
+
74
+ ```mermaid
75
+ flowchart LR
76
+ B[Browser / Mobile] --> P[DevLinker Proxy]
77
+ P --> F[Frontend Dev Server\nVite/React]
78
+ P --> A[Backend API\nFastAPI/Flask/Node]
79
+ P --> T[Optional Tunnel\nCloudflare/ngrok]
80
+ ```
81
+
82
+ ## 🎯 Use Cases
83
+
84
+ - Test APIs and UI flows on mobile devices over WLAN
85
+ - Share local work instantly with teammates using one public URL
86
+ - Debug frontend-backend integration from a single entrypoint
87
+ - Reduce CORS/preflight issues during development
88
+
89
+ ## 🖼️ Demo & Screenshots
90
+
91
+ - Terminal startup output: add screenshot at docs/images/terminal-startup.png
92
+ - Browser app via proxy: add screenshot at docs/images/browser-proxy.png
93
+ - Public URL share demo: add screenshot at docs/images/public-url.png
94
+
25
95
 
26
96
  ## Features
27
97
 
@@ -36,6 +106,8 @@ Dev Linker starts your local development stack and routes frontend and backend t
36
106
  - 🌍 **Public Sharing:** Share your local dev environment instantly with `--url` (startup) or `devlinker share` (runtime, no restart).
37
107
  - 🔄 **Dynamic Tunnel Control:** `devlinker unshare` disables public tunnel at runtime.
38
108
  - 📡 **WLAN Sharing:** Prints LAN URL for same-network device access.
109
+ - 🔒 **Secure Token Linking:** Optional token gate for LAN/public access with `DEVLINKER_LINK_TOKEN`.
110
+ - 📊 **Browser API Logs Dashboard:** Open `/__devlinker/dashboard` for lightweight live API visibility.
39
111
  - 🧑‍💻 **Interactive CLI:** Modern, colorized, emoji-rich terminal UX for all commands.
40
112
  - 🧩 **Zero Config:** Works out-of-the-box for most FastAPI, Flask, Vite, and Docker projects.
41
113
  - 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
@@ -58,6 +130,7 @@ If DevLinker helps you ship faster, consider supporting the project:
58
130
  - `devlinker support` — Show UPI support QR code in terminal
59
131
  - `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
60
132
  - `devlinker share` — Enable public tunnel at runtime (no restart)
133
+ - `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
61
134
  - `devlinker unshare` — Disable public tunnel at runtime
62
135
  - `devlinker doctor` — Diagnose issues, see categorized problems and fixes
63
136
  - `devlinker fix` — Auto-fix common issues (env, API paths, config)
@@ -70,6 +143,30 @@ If DevLinker helps you ship faster, consider supporting the project:
70
143
  - `devlinker --debug` — Enable debug mode (turns on live API request logger)
71
144
  - `devlinker --version` — Show version
72
145
 
146
+ Security token (optional):
147
+
148
+ ```bash
149
+ set DEVLINKER_LINK_TOKEN=your-secret-token
150
+ devlinker --url
151
+ ```
152
+
153
+ When enabled, LAN/public requests must include one of:
154
+ - query param `dl_token=...`
155
+ - header `X-DevLinker-Token: ...`
156
+ - header `Authorization: Bearer ...`
157
+
158
+ Built-in API logs dashboard:
159
+
160
+ ```text
161
+ http://localhost:<proxy-port>/__devlinker/dashboard
162
+ ```
163
+
164
+ JSON stream endpoint used by the dashboard:
165
+
166
+ ```text
167
+ http://localhost:<proxy-port>/__devlinker/logs
168
+ ```
169
+
73
170
  ## Project Structure
74
171
 
75
172
  ```text
@@ -182,14 +279,16 @@ devlinker --docker
182
279
 
183
280
  ## Tunnel and Sharing Modes
184
281
 
185
- By default, DevLinker starts **fast local proxy only** (no tunnel). To enable a public tunnel, use the `--url` flag:
282
+ By default, DevLinker starts **fast local proxy only** (no tunnel). It prints a LAN URL when it can detect a local network interface, and you can share that link with devices on the same Wi-Fi/LAN.
283
+
284
+ For access from another network, start with a public tunnel using the `--url` flag:
186
285
 
187
286
 
188
287
  ```bash
189
288
  devlinker --url
190
289
  ```
191
290
 
192
- This will start the proxy and open a public tunnel (Cloudflare or ngrok). The output will show:
291
+ This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
193
292
 
194
293
  ```text
195
294
  🌍 Enabling public tunnel...
@@ -200,6 +299,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
200
299
  ℹ Share this link with collaborators.
201
300
  ```
202
301
 
302
+ If you already started DevLinker and want to turn on sharing without restarting, use:
303
+
304
+ ```bash
305
+ devlinker share
306
+ ```
307
+
308
+ If you use a custom proxy port, pass it explicitly:
309
+
310
+ ```bash
311
+ devlinker share --proxy-port 18000
312
+ ```
313
+
314
+ If your friend is on the same Wi-Fi/LAN, use the printed LAN URL like `http://192.168.x.x:<proxy-port>`. If they are outside your network, use the public tunnel URL instead.
315
+
203
316
  To force tunnel off (even if --url is passed):
204
317
 
205
318
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlinker"
7
- version = "1.4.1"
7
+ version = "1.4.3"
8
8
  description = "A lightweight proxy that combines your frontend and backend into one link for easy development and sharing."
9
9
  authors = [
10
10
  { name = "Mani", email = "mani1028@users.noreply.github.com" }
@@ -1,32 +0,0 @@
1
-
2
- import click
3
- from devlinker.global_state import STATE
4
- from devlinker.tunnel import start_tunnel
5
-
6
- @click.command()
7
- def share():
8
- """Enable public tunnel at runtime (no restart)."""
9
- if STATE["tunnel"]:
10
- click.secho("⚠️ Already shared", fg="yellow")
11
- return
12
- try:
13
- provider, url = start_tunnel(STATE["proxy_port"])
14
- STATE["tunnel"] = url
15
- click.secho("\n🌍 Public Sharing Enabled\n" + ("─" * 24), fg="green", bold=True)
16
- click.secho("✔ Tunnel connected", fg="green")
17
- click.secho(f"\nPublic URL:\n{url}\n", fg="cyan", bold=True)
18
- click.secho("📤 Share this link with your team", fg="magenta")
19
- except Exception as exc:
20
- click.secho(f"[WARN] Tunnel failed: {exc}", fg="red")
21
- click.secho("[INFO] Next step: install cloudflared or configure ngrok auth.", fg="yellow")
22
-
23
- @click.command()
24
- def unshare():
25
- """Disable public tunnel at runtime (no restart)."""
26
- if not STATE["tunnel"]:
27
- click.secho("⚠️ No active tunnel", fg="yellow")
28
- return
29
- from devlinker.tunnel import stop_tunnel
30
- stop_tunnel()
31
- STATE["tunnel"] = None
32
- click.secho("🛑 Sharing stopped", fg="red", bold=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes