devlinker 1.3.3__tar.gz → 1.3.5__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.
- devlinker-1.3.5/LICENSE +21 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/PKG-INFO +3 -1
- devlinker-1.3.5/devlinker/detection_state.py +66 -0
- devlinker-1.3.5/devlinker/global_state.py +5 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/proxy.py +60 -21
- devlinker-1.3.5/devlinker/share.py +32 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/tunnel.py +52 -9
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker.egg-info/PKG-INFO +3 -1
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker.egg-info/SOURCES.txt +2 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/pyproject.toml +1 -1
- devlinker-1.3.3/devlinker/detection_state.py +0 -58
- devlinker-1.3.3/devlinker/share.py +0 -54
- {devlinker-1.3.3 → devlinker-1.3.5}/README.md +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/__init__.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/config.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/detector.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/detector_ai.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/doctor.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/fix.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/fixer.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/inspect.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/logger.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/main.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/monitor.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker/runner.py +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker.egg-info/dependency_links.txt +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker.egg-info/entry_points.txt +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker.egg-info/requires.txt +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/devlinker.egg-info/top_level.txt +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/setup.cfg +0 -0
- {devlinker-1.3.3 → devlinker-1.3.5}/setup.py +0 -0
devlinker-1.3.5/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevLinker Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlinker
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.5
|
|
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
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
8
9
|
Requires-Dist: click
|
|
9
10
|
Requires-Dist: docker
|
|
10
11
|
Requires-Dist: fastapi
|
|
@@ -13,6 +14,7 @@ Requires-Dist: pyngrok
|
|
|
13
14
|
Requires-Dist: requests
|
|
14
15
|
Requires-Dist: uvicorn
|
|
15
16
|
Requires-Dist: websockets
|
|
17
|
+
Dynamic: license-file
|
|
16
18
|
|
|
17
19
|
# Dev Linker
|
|
18
20
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
|
|
2
|
+
class DetectionState:
|
|
3
|
+
def __init__(self):
|
|
4
|
+
self.issues = []
|
|
5
|
+
self.counts = {}
|
|
6
|
+
self.levels = {}
|
|
7
|
+
self.categories = {}
|
|
8
|
+
|
|
9
|
+
def add(self, issue, level="MEDIUM", category="general"):
|
|
10
|
+
key = issue.strip().lower()
|
|
11
|
+
if key in self.counts:
|
|
12
|
+
self.counts[key] += 1
|
|
13
|
+
return False # already shown
|
|
14
|
+
else:
|
|
15
|
+
self.counts[key] = 1
|
|
16
|
+
self.issues.append({
|
|
17
|
+
"issue": issue,
|
|
18
|
+
"level": level,
|
|
19
|
+
"category": category
|
|
20
|
+
})
|
|
21
|
+
return True # first time
|
|
22
|
+
|
|
23
|
+
def get_count(self, issue):
|
|
24
|
+
key = issue.strip().lower()
|
|
25
|
+
return self.counts.get(key, 0)
|
|
26
|
+
|
|
27
|
+
def should_print(self, issue):
|
|
28
|
+
key = issue.strip().lower()
|
|
29
|
+
return self.counts.get(key, 0) == 1
|
|
30
|
+
|
|
31
|
+
def get_issues(self):
|
|
32
|
+
return [
|
|
33
|
+
(i["issue"], i["level"], self.get_count(i["issue"]), i["category"])
|
|
34
|
+
for i in self.issues
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def summary(self):
|
|
38
|
+
summary = {}
|
|
39
|
+
for i in self.issues:
|
|
40
|
+
level = i["level"]
|
|
41
|
+
summary.setdefault(level, set()).add(i["issue"])
|
|
42
|
+
return summary
|
|
43
|
+
|
|
44
|
+
def _get_category(self, issue):
|
|
45
|
+
for i in self.issues:
|
|
46
|
+
if i["issue"] == issue:
|
|
47
|
+
return i["category"]
|
|
48
|
+
return "general"
|
|
49
|
+
|
|
50
|
+
def report(self):
|
|
51
|
+
print("\n🩺 DevLinker Doctor Report\n────────────────────────")
|
|
52
|
+
# Group by category
|
|
53
|
+
for i in self.issues:
|
|
54
|
+
issue = i["issue"]
|
|
55
|
+
level = i["level"]
|
|
56
|
+
category = i["category"]
|
|
57
|
+
count = self.get_count(issue)
|
|
58
|
+
if level == "HIGH":
|
|
59
|
+
print(f"❌ {issue} (x{count})")
|
|
60
|
+
elif level == "MEDIUM":
|
|
61
|
+
print(f"⚠️ {issue} (x{count})")
|
|
62
|
+
else:
|
|
63
|
+
print(f"💡 {issue} (x{count})")
|
|
64
|
+
|
|
65
|
+
# Singleton instance
|
|
66
|
+
state = DetectionState()
|
|
@@ -23,23 +23,33 @@ _recent_requests = []
|
|
|
23
23
|
_recent_lock = threading.Lock()
|
|
24
24
|
|
|
25
25
|
class RequestInspector:
|
|
26
|
-
def analyze(self, path, status, target):
|
|
26
|
+
def analyze(self, path, status, target, method=None, response_text=None):
|
|
27
27
|
warnings = []
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
warnings
|
|
33
|
-
|
|
28
|
+
# Ignore static files and paths
|
|
29
|
+
static_exts = [".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".svg", ".woff", ".woff2", ".ttf", ".map"]
|
|
30
|
+
IGNORE_PATHS = ["/@vite", "/assets", "/favicon.ico", "/src", "/node_modules"]
|
|
31
|
+
if any(path.endswith(ext) for ext in static_exts):
|
|
32
|
+
return warnings
|
|
33
|
+
if any(path.startswith(p) for p in IGNORE_PATHS):
|
|
34
|
+
return warnings
|
|
35
|
+
# Only warn for missing /api prefix if status is 404, method is POST/PUT/DELETE, and not static/ignored
|
|
36
|
+
if status == 404 and method and method.upper() in ["POST", "PUT", "DELETE"]:
|
|
37
|
+
if not path.startswith("/api"):
|
|
38
|
+
# Optionally, check response_text for "Not Found"
|
|
39
|
+
if response_text is None or "not found" in response_text.lower():
|
|
40
|
+
issue = f"Possible missing '/api' prefix on {path} [{method}]"
|
|
41
|
+
if state.add(issue, level="MEDIUM", category="routing"):
|
|
42
|
+
warnings.append(issue)
|
|
43
|
+
# 2. 404 detection (general)
|
|
34
44
|
if status == 404:
|
|
35
|
-
issue = "Route not found → check backend route"
|
|
36
|
-
state.add(issue, level="HIGH", category="routing")
|
|
37
|
-
|
|
45
|
+
issue = f"Route not found → check backend route: {path}"
|
|
46
|
+
if state.add(issue, level="HIGH", category="routing"):
|
|
47
|
+
warnings.append(issue)
|
|
38
48
|
# 3. Upstream failure
|
|
39
49
|
if status == 502:
|
|
40
|
-
issue = "Backend unreachable"
|
|
41
|
-
state.add(issue, level="HIGH", category="network")
|
|
42
|
-
|
|
50
|
+
issue = f"Backend unreachable: {path}"
|
|
51
|
+
if state.add(issue, level="HIGH", category="network"):
|
|
52
|
+
warnings.append(issue)
|
|
43
53
|
# Log request for inspector
|
|
44
54
|
with _recent_lock:
|
|
45
55
|
_recent_requests.append({"path": path, "status": status, "target": target})
|
|
@@ -142,12 +152,15 @@ async def _forward_http(request: Request) -> Response:
|
|
|
142
152
|
if target_port is None:
|
|
143
153
|
if request.url.path.startswith("/api"):
|
|
144
154
|
status = 503
|
|
145
|
-
warnings = inspector.analyze(request.url.path, status, "backend")
|
|
155
|
+
warnings = inspector.analyze(request.url.path, status, "backend", method=request.method)
|
|
146
156
|
for w in warnings:
|
|
147
|
-
|
|
157
|
+
if "/api" in w:
|
|
158
|
+
print_warning(f"API routing issue detected\n👉 {request.url.path} returned 404\n👉 Try: /api{request.url.path}")
|
|
159
|
+
else:
|
|
160
|
+
print_warning(w)
|
|
148
161
|
return PlainTextResponse("Backend is not configured.", status_code=status)
|
|
149
162
|
status = 503
|
|
150
|
-
warnings = inspector.analyze(request.url.path, status, "frontend")
|
|
163
|
+
warnings = inspector.analyze(request.url.path, status, "frontend", method=request.method)
|
|
151
164
|
for w in warnings:
|
|
152
165
|
print_warning(w)
|
|
153
166
|
return PlainTextResponse("Frontend is not configured.", status_code=status)
|
|
@@ -169,7 +182,7 @@ async def _forward_http(request: Request) -> Response:
|
|
|
169
182
|
)
|
|
170
183
|
except httpx.RequestError as exc:
|
|
171
184
|
status = 502
|
|
172
|
-
warnings = inspector.analyze(request.url.path, status, "backend")
|
|
185
|
+
warnings = inspector.analyze(request.url.path, status, "backend", method=request.method)
|
|
173
186
|
for w in warnings:
|
|
174
187
|
print_warning(w)
|
|
175
188
|
ai_suggestions = ai.analyze_failure(str(exc))
|
|
@@ -178,17 +191,43 @@ async def _forward_http(request: Request) -> Response:
|
|
|
178
191
|
return PlainTextResponse(f"Upstream unavailable: {exc}", status_code=status)
|
|
179
192
|
|
|
180
193
|
# Analyze response for warnings and fixes
|
|
181
|
-
warnings = inspector.analyze(
|
|
194
|
+
warnings = inspector.analyze(
|
|
195
|
+
request.url.path,
|
|
196
|
+
upstream.status_code,
|
|
197
|
+
"backend",
|
|
198
|
+
method=request.method,
|
|
199
|
+
response_text=upstream.text
|
|
200
|
+
)
|
|
182
201
|
for w in warnings:
|
|
183
|
-
|
|
202
|
+
if "/api" in w:
|
|
203
|
+
print_warning(f"API routing issue detected\n👉 {request.url.path} returned 404\n👉 Try: /api{request.url.path}")
|
|
204
|
+
else:
|
|
205
|
+
print_warning(w)
|
|
184
206
|
ai_suggestions = ai.analyze_failure(str(upstream.text))
|
|
185
207
|
for s in ai_suggestions:
|
|
186
208
|
print_fix(s)
|
|
187
209
|
|
|
210
|
+
# Only inject loader for HTML responses, not localhost
|
|
211
|
+
headers = _filter_response_headers(dict(upstream.headers))
|
|
212
|
+
content_type = headers.get("content-type", "")
|
|
213
|
+
is_html = "text/html" in content_type
|
|
214
|
+
is_public = not (request.client and request.client.host in ("127.0.0.1", "localhost"))
|
|
215
|
+
content = upstream.content
|
|
216
|
+
if is_html and is_public:
|
|
217
|
+
try:
|
|
218
|
+
html = content.decode(upstream.encoding or "utf-8", errors="replace")
|
|
219
|
+
# Only inject if </body> exists
|
|
220
|
+
if "</body>" in html:
|
|
221
|
+
with open(__import__('os').path.join(__import__('os').path.dirname(__file__), "devlinker_loader_snippet.html"), encoding="utf-8") as f:
|
|
222
|
+
loader = f.read()
|
|
223
|
+
html = html.replace("</body>", loader + "</body>")
|
|
224
|
+
content = html.encode(upstream.encoding or "utf-8")
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
188
227
|
return Response(
|
|
189
|
-
content=
|
|
228
|
+
content=content,
|
|
190
229
|
status_code=upstream.status_code,
|
|
191
|
-
headers=
|
|
230
|
+
headers=headers,
|
|
192
231
|
)
|
|
193
232
|
|
|
194
233
|
|
|
@@ -0,0 +1,32 @@
|
|
|
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)
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
def stop_tunnel():
|
|
4
|
+
"""Stop all active tunnels (Cloudflare/ngrok)."""
|
|
5
|
+
# Stop ngrok tunnels
|
|
6
|
+
try:
|
|
7
|
+
for tunnel in ngrok.get_tunnels():
|
|
8
|
+
public_url = getattr(tunnel, "public_url", None)
|
|
9
|
+
if public_url is not None:
|
|
10
|
+
ngrok.disconnect(public_url)
|
|
11
|
+
except Exception:
|
|
12
|
+
pass
|
|
13
|
+
# Stop cloudflared processes
|
|
14
|
+
global _CLOUDFLARED_PROCESSES
|
|
15
|
+
for proc in _CLOUDFLARED_PROCESSES:
|
|
16
|
+
try:
|
|
17
|
+
proc.terminate()
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
_CLOUDFLARED_PROCESSES.clear()
|
|
21
|
+
|
|
3
22
|
import re
|
|
4
23
|
import shutil
|
|
5
24
|
import subprocess
|
|
@@ -42,7 +61,21 @@ def _try_cloudflare(proxy_port: int, startup_timeout: float = 12.0) -> str | Non
|
|
|
42
61
|
stdout, _ = process.communicate(timeout=startup_timeout)
|
|
43
62
|
output = stdout or ""
|
|
44
63
|
except TimeoutExpired as exc:
|
|
45
|
-
|
|
64
|
+
# exc.stdout and exc.stderr may be str, bytes, bytearray, or memoryview; convert to str
|
|
65
|
+
def to_str(val):
|
|
66
|
+
if isinstance(val, str):
|
|
67
|
+
return val
|
|
68
|
+
if isinstance(val, (bytes, bytearray)):
|
|
69
|
+
return val.decode(errors="replace")
|
|
70
|
+
if isinstance(val, memoryview):
|
|
71
|
+
return val.tobytes().decode(errors="replace")
|
|
72
|
+
return str(val) if val is not None else ""
|
|
73
|
+
exc_stdout = to_str(exc.stdout)
|
|
74
|
+
exc_stderr = to_str(exc.stderr)
|
|
75
|
+
output = exc_stdout + exc_stderr
|
|
76
|
+
|
|
77
|
+
if not isinstance(output, str):
|
|
78
|
+
output = str(output)
|
|
46
79
|
|
|
47
80
|
url = _extract_trycloudflare_url(output)
|
|
48
81
|
if url:
|
|
@@ -55,22 +88,30 @@ def _try_cloudflare(proxy_port: int, startup_timeout: float = 12.0) -> str | Non
|
|
|
55
88
|
|
|
56
89
|
def _disconnect_existing_tunnels() -> None:
|
|
57
90
|
for tunnel in ngrok.get_tunnels():
|
|
58
|
-
|
|
91
|
+
public_url = getattr(tunnel, "public_url", None)
|
|
92
|
+
if public_url is not None:
|
|
93
|
+
ngrok.disconnect(public_url)
|
|
59
94
|
|
|
60
95
|
|
|
61
|
-
def _start_ngrok_tunnel(proxy_port: int) -> str:
|
|
96
|
+
def _start_ngrok_tunnel(proxy_port: int) -> str | None:
|
|
62
97
|
try:
|
|
63
|
-
tunnel = ngrok.connect(proxy_port)
|
|
64
|
-
|
|
98
|
+
tunnel = ngrok.connect(str(proxy_port))
|
|
99
|
+
public_url = getattr(tunnel, "public_url", None)
|
|
100
|
+
if public_url is not None:
|
|
101
|
+
return public_url
|
|
102
|
+
return None
|
|
65
103
|
except PyngrokError as exc:
|
|
66
104
|
message = str(exc).lower()
|
|
67
105
|
|
|
68
106
|
# If a prior endpoint is still active, disconnect and retry once.
|
|
69
|
-
if "already online" in message or "endpoint" in message and "online" in message:
|
|
107
|
+
if "already online" in message or ("endpoint" in message and "online" in message):
|
|
70
108
|
try:
|
|
71
109
|
_disconnect_existing_tunnels()
|
|
72
|
-
tunnel = ngrok.connect(proxy_port)
|
|
73
|
-
|
|
110
|
+
tunnel = ngrok.connect(str(proxy_port))
|
|
111
|
+
public_url = getattr(tunnel, "public_url", None)
|
|
112
|
+
if public_url is not None:
|
|
113
|
+
return public_url
|
|
114
|
+
return None
|
|
74
115
|
except PyngrokError as retry_exc:
|
|
75
116
|
raise RuntimeError(
|
|
76
117
|
f"Failed to start ngrok tunnel after disconnect retry: {retry_exc}"
|
|
@@ -97,7 +138,9 @@ def start_tunnel(proxy_port: int = 8000) -> tuple[str, str]:
|
|
|
97
138
|
|
|
98
139
|
try:
|
|
99
140
|
ngrok_url = _start_ngrok_tunnel(proxy_port)
|
|
100
|
-
|
|
141
|
+
if ngrok_url is not None:
|
|
142
|
+
return "ngrok", ngrok_url
|
|
143
|
+
raise RuntimeError("Ngrok tunnel did not return a public URL.")
|
|
101
144
|
except RuntimeError as ngrok_error:
|
|
102
145
|
raise RuntimeError(
|
|
103
146
|
"No tunnel available.\n"
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlinker
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.5
|
|
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
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
8
9
|
Requires-Dist: click
|
|
9
10
|
Requires-Dist: docker
|
|
10
11
|
Requires-Dist: fastapi
|
|
@@ -13,6 +14,7 @@ Requires-Dist: pyngrok
|
|
|
13
14
|
Requires-Dist: requests
|
|
14
15
|
Requires-Dist: uvicorn
|
|
15
16
|
Requires-Dist: websockets
|
|
17
|
+
Dynamic: license-file
|
|
16
18
|
|
|
17
19
|
# Dev Linker
|
|
18
20
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devlinker"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.5"
|
|
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,58 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
class DetectionState:
|
|
3
|
-
def __init__(self):
|
|
4
|
-
self.issues = []
|
|
5
|
-
self.counts = {}
|
|
6
|
-
self.levels = {}
|
|
7
|
-
self.categories = {}
|
|
8
|
-
|
|
9
|
-
def add(self, issue, level="MEDIUM", category="general"):
|
|
10
|
-
self.issues.append(issue)
|
|
11
|
-
self.counts[issue] = self.counts.get(issue, 0) + 1
|
|
12
|
-
self.levels[issue] = level
|
|
13
|
-
self.categories.setdefault(category, []).append(issue)
|
|
14
|
-
|
|
15
|
-
def should_print(self, issue):
|
|
16
|
-
return self.counts.get(issue, 0) == 1
|
|
17
|
-
|
|
18
|
-
def get_issues(self):
|
|
19
|
-
return [(issue, self.levels.get(issue, "MEDIUM"), self.counts[issue], self._get_category(issue)) for issue in self.issues]
|
|
20
|
-
|
|
21
|
-
def summary(self):
|
|
22
|
-
summary = {}
|
|
23
|
-
for issue in self.issues:
|
|
24
|
-
level = self.levels.get(issue, "MEDIUM")
|
|
25
|
-
summary.setdefault(level, set()).add(issue)
|
|
26
|
-
return summary
|
|
27
|
-
|
|
28
|
-
def _get_category(self, issue):
|
|
29
|
-
for cat, issues in self.categories.items():
|
|
30
|
-
if issue in issues:
|
|
31
|
-
return cat
|
|
32
|
-
return "general"
|
|
33
|
-
|
|
34
|
-
def report(self):
|
|
35
|
-
print("\n🩺 DevLinker Doctor Report\n────────────────────────")
|
|
36
|
-
# Group by category
|
|
37
|
-
for category, issues in self.categories.items():
|
|
38
|
-
if not issues:
|
|
39
|
-
continue
|
|
40
|
-
if category == "network":
|
|
41
|
-
print("\n🌐 Network Issues")
|
|
42
|
-
elif category == "routing":
|
|
43
|
-
print("\n🔀 Routing Issues")
|
|
44
|
-
elif category == "cors":
|
|
45
|
-
print("\n🔐 CORS Issues")
|
|
46
|
-
else:
|
|
47
|
-
print(f"\n{category.title()} Issues")
|
|
48
|
-
for issue in set(issues):
|
|
49
|
-
level = self.levels.get(issue, "MEDIUM")
|
|
50
|
-
if level == "HIGH":
|
|
51
|
-
print(f"❌ {issue}")
|
|
52
|
-
elif level == "MEDIUM":
|
|
53
|
-
print(f"⚠️ {issue}")
|
|
54
|
-
else:
|
|
55
|
-
print(f"💡 {issue}")
|
|
56
|
-
|
|
57
|
-
# Singleton instance
|
|
58
|
-
state = DetectionState()
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import click
|
|
2
|
-
from devlinker.tunnel import start_tunnel
|
|
3
|
-
|
|
4
|
-
# Global tunnel state
|
|
5
|
-
_tunnel_info = {
|
|
6
|
-
"provider": None,
|
|
7
|
-
"public_url": None,
|
|
8
|
-
"active": False,
|
|
9
|
-
"proxy_port": None,
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
@click.command()
|
|
13
|
-
def share():
|
|
14
|
-
"""Enable public tunnel at runtime (no restart)."""
|
|
15
|
-
from devlinker.main import _select_proxy_port
|
|
16
|
-
import sys
|
|
17
|
-
proxy_port = 8000
|
|
18
|
-
banner = "\n" + ("═" * 36) + "\n🌍 DevLinker Share Mode\n" + ("═" * 36)
|
|
19
|
-
if _tunnel_info["active"]:
|
|
20
|
-
click.secho(f"{banner}\n\n🔗 Tunnel already active:", fg="yellow", bold=True)
|
|
21
|
-
click.secho(f" {_tunnel_info['public_url']}\n", fg="cyan", bold=True)
|
|
22
|
-
return
|
|
23
|
-
try:
|
|
24
|
-
click.secho(f"{banner}\n\n🌍 Enabling public tunnel...", fg="green", bold=True)
|
|
25
|
-
provider, public_url = start_tunnel(proxy_port)
|
|
26
|
-
_tunnel_info["provider"] = provider
|
|
27
|
-
_tunnel_info["public_url"] = public_url
|
|
28
|
-
_tunnel_info["active"] = True
|
|
29
|
-
_tunnel_info["proxy_port"] = proxy_port
|
|
30
|
-
click.secho(f"\n[OK] Tunnel provider: {provider}", fg="blue")
|
|
31
|
-
click.secho(f"[OK] Public URL:", fg="blue")
|
|
32
|
-
click.secho(f" {public_url}\n", fg="cyan", bold=True)
|
|
33
|
-
click.secho("Tip: Press Ctrl+Click to open link", fg="magenta")
|
|
34
|
-
click.secho("[INFO] Share this link with collaborators.\n", fg="magenta")
|
|
35
|
-
except Exception as exc:
|
|
36
|
-
click.secho(f"[WARN] Tunnel failed: {exc}", fg="red")
|
|
37
|
-
click.secho("[INFO] Next step: install cloudflared or configure ngrok auth.", fg="yellow")
|
|
38
|
-
sys.exit(1)
|
|
39
|
-
|
|
40
|
-
@click.command()
|
|
41
|
-
def unshare():
|
|
42
|
-
"""Disable public tunnel at runtime (no restart)."""
|
|
43
|
-
banner = "\n" + ("═" * 36) + "\n🛑 DevLinker Unshare Mode\n" + ("═" * 36)
|
|
44
|
-
if not _tunnel_info["active"]:
|
|
45
|
-
click.secho(f"{banner}\n\nNo tunnel is currently active.\n", fg="yellow", bold=True)
|
|
46
|
-
return
|
|
47
|
-
# In a real implementation, stop the tunnel process here
|
|
48
|
-
click.secho(f"{banner}\n\n🛑 Disabling tunnel:", fg="red", bold=True)
|
|
49
|
-
click.secho(f" {_tunnel_info['public_url']}\n", fg="cyan", bold=True)
|
|
50
|
-
_tunnel_info["provider"] = None
|
|
51
|
-
_tunnel_info["public_url"] = None
|
|
52
|
-
_tunnel_info["active"] = False
|
|
53
|
-
_tunnel_info["proxy_port"] = None
|
|
54
|
-
click.secho("[OK] Tunnel disabled.\n", fg="green", 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
|
|
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
|