devlinker 0.1.0__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-0.1.0/PKG-INFO +138 -0
- devlinker-0.1.0/README.md +126 -0
- devlinker-0.1.0/devlinker/__init__.py +11 -0
- devlinker-0.1.0/devlinker/detector.py +68 -0
- devlinker-0.1.0/devlinker/main.py +119 -0
- devlinker-0.1.0/devlinker/proxy.py +88 -0
- devlinker-0.1.0/devlinker/runner.py +68 -0
- devlinker-0.1.0/devlinker/tunnel.py +107 -0
- devlinker-0.1.0/devlinker.egg-info/PKG-INFO +138 -0
- devlinker-0.1.0/devlinker.egg-info/SOURCES.txt +15 -0
- devlinker-0.1.0/devlinker.egg-info/dependency_links.txt +1 -0
- devlinker-0.1.0/devlinker.egg-info/entry_points.txt +2 -0
- devlinker-0.1.0/devlinker.egg-info/requires.txt +4 -0
- devlinker-0.1.0/devlinker.egg-info/top_level.txt +1 -0
- devlinker-0.1.0/pyproject.toml +25 -0
- devlinker-0.1.0/setup.cfg +4 -0
- devlinker-0.1.0/setup.py +3 -0
devlinker-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devlinker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered linking and automation tool
|
|
5
|
+
Author-email: Mani <mani1028@users.noreply.github.com>
|
|
6
|
+
Requires-Python: >=3.7
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: flask
|
|
10
|
+
Requires-Dist: pyngrok
|
|
11
|
+
Requires-Dist: requests
|
|
12
|
+
|
|
13
|
+
# Dev Linker
|
|
14
|
+
|
|
15
|
+
Dev Linker runs frontend and backend dev servers, proxies both through a single local port (8000), and creates a single public URL via Cloudflare or ngrok.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Launches frontend and backend (when frontend and backend/app.py exist)
|
|
20
|
+
- Detects common frontend/backend ports
|
|
21
|
+
- Serves both through one proxy at http://localhost:8000
|
|
22
|
+
- Creates a public tunnel for sharing (Cloudflare first, ngrok fallback)
|
|
23
|
+
- Terminal-first workflow
|
|
24
|
+
- Supports CLI version output with --version
|
|
25
|
+
|
|
26
|
+
## Project Structure
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
onelink/
|
|
30
|
+
├── onelink/
|
|
31
|
+
│ ├── __init__.py
|
|
32
|
+
│ ├── main.py
|
|
33
|
+
│ ├── runner.py
|
|
34
|
+
│ ├── detector.py
|
|
35
|
+
│ ├── proxy.py
|
|
36
|
+
│ └── tunnel.py
|
|
37
|
+
├── setup.py
|
|
38
|
+
├── README.md
|
|
39
|
+
└── requirements.txt
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
For local development:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
After publishing to PyPI:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install dev-linker
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Run
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
devlinker
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Typical startup output:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
✨ Dev Linker v0.1.0
|
|
66
|
+
|
|
67
|
+
🚀 Starting services...
|
|
68
|
+
🔍 Detecting services...
|
|
69
|
+
• Frontend -> 5173
|
|
70
|
+
• Backend -> 5000
|
|
71
|
+
|
|
72
|
+
🌐 Proxy ready at http://localhost:8000
|
|
73
|
+
|
|
74
|
+
⚡ Tunnel provider: Cloudflare
|
|
75
|
+
🌍 Public URL:
|
|
76
|
+
https://xxxx.trycloudflare.com
|
|
77
|
+
|
|
78
|
+
👉 Share this link with anyone
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Version check:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
devlinker --version
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Optional overrides:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
devlinker --frontend 5173 --backend 5000
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If port 8000 is already in use:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
devlinker --frontend 5173 --backend 5000 --proxy-port 18000
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Default behavior also tries fallback ports automatically when 8000 is busy:
|
|
100
|
+
|
|
101
|
+
- 8001
|
|
102
|
+
- 8002
|
|
103
|
+
- 18000
|
|
104
|
+
|
|
105
|
+
## Important Frontend Rule
|
|
106
|
+
|
|
107
|
+
Frontend requests must use relative API paths:
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
fetch("/api/endpoint")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Do not hardcode backend host URLs in frontend code.
|
|
114
|
+
|
|
115
|
+
## Notes
|
|
116
|
+
|
|
117
|
+
- runner.py expects frontend project in frontend and Flask app in backend/app.py.
|
|
118
|
+
- If those paths do not exist, Dev Linker skips launch and only tries to detect already-running services.
|
|
119
|
+
- Tunnel selection order is: cloudflared (TryCloudflare), then ngrok.
|
|
120
|
+
- If cloudflared is unavailable and ngrok is not configured, Dev Linker prints one-time setup guidance.
|
|
121
|
+
- You may need to set ngrok auth once on your machine using ngrok config add-authtoken <token>.
|
|
122
|
+
- Dev Linker prints a public URL with `ngrok-skip-browser-warning=true` only when ngrok is used.
|
|
123
|
+
- Startup output includes selected tunnel provider (`cloudflare` or `ngrok`).
|
|
124
|
+
- When Dev Linker launches a Vite frontend, it sets `ONELINK=1` to disable Vite HMR WebSockets for stable tunnel behavior.
|
|
125
|
+
|
|
126
|
+
## Real-Time Development Modes
|
|
127
|
+
|
|
128
|
+
### Option 1: Dev Linker sharing mode (recommended)
|
|
129
|
+
|
|
130
|
+
- Run `devlinker` to share one combined frontend/backend URL.
|
|
131
|
+
- Open local Vite URL yourself for instant HMR updates.
|
|
132
|
+
- Share Dev Linker/ngrok URL with others; they can use normal page refresh to see changes.
|
|
133
|
+
|
|
134
|
+
### Option 2: Full remote HMR mode (bypass Dev Linker)
|
|
135
|
+
|
|
136
|
+
- Start frontend and backend manually.
|
|
137
|
+
- Configure Vite `server.proxy` for `/api` to backend.
|
|
138
|
+
- Run `ngrok http <vite-port>` directly so Vite handles WebSocket HMR traffic.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Dev Linker
|
|
2
|
+
|
|
3
|
+
Dev Linker runs frontend and backend dev servers, proxies both through a single local port (8000), and creates a single public URL via Cloudflare or ngrok.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Launches frontend and backend (when frontend and backend/app.py exist)
|
|
8
|
+
- Detects common frontend/backend ports
|
|
9
|
+
- Serves both through one proxy at http://localhost:8000
|
|
10
|
+
- Creates a public tunnel for sharing (Cloudflare first, ngrok fallback)
|
|
11
|
+
- Terminal-first workflow
|
|
12
|
+
- Supports CLI version output with --version
|
|
13
|
+
|
|
14
|
+
## Project Structure
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
onelink/
|
|
18
|
+
├── onelink/
|
|
19
|
+
│ ├── __init__.py
|
|
20
|
+
│ ├── main.py
|
|
21
|
+
│ ├── runner.py
|
|
22
|
+
│ ├── detector.py
|
|
23
|
+
│ ├── proxy.py
|
|
24
|
+
│ └── tunnel.py
|
|
25
|
+
├── setup.py
|
|
26
|
+
├── README.md
|
|
27
|
+
└── requirements.txt
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
For local development:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
After publishing to PyPI:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install dev-linker
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Run
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
devlinker
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Typical startup output:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
✨ Dev Linker v0.1.0
|
|
54
|
+
|
|
55
|
+
🚀 Starting services...
|
|
56
|
+
🔍 Detecting services...
|
|
57
|
+
• Frontend -> 5173
|
|
58
|
+
• Backend -> 5000
|
|
59
|
+
|
|
60
|
+
🌐 Proxy ready at http://localhost:8000
|
|
61
|
+
|
|
62
|
+
⚡ Tunnel provider: Cloudflare
|
|
63
|
+
🌍 Public URL:
|
|
64
|
+
https://xxxx.trycloudflare.com
|
|
65
|
+
|
|
66
|
+
👉 Share this link with anyone
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Version check:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
devlinker --version
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Optional overrides:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
devlinker --frontend 5173 --backend 5000
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If port 8000 is already in use:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
devlinker --frontend 5173 --backend 5000 --proxy-port 18000
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Default behavior also tries fallback ports automatically when 8000 is busy:
|
|
88
|
+
|
|
89
|
+
- 8001
|
|
90
|
+
- 8002
|
|
91
|
+
- 18000
|
|
92
|
+
|
|
93
|
+
## Important Frontend Rule
|
|
94
|
+
|
|
95
|
+
Frontend requests must use relative API paths:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
fetch("/api/endpoint")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Do not hardcode backend host URLs in frontend code.
|
|
102
|
+
|
|
103
|
+
## Notes
|
|
104
|
+
|
|
105
|
+
- runner.py expects frontend project in frontend and Flask app in backend/app.py.
|
|
106
|
+
- If those paths do not exist, Dev Linker skips launch and only tries to detect already-running services.
|
|
107
|
+
- Tunnel selection order is: cloudflared (TryCloudflare), then ngrok.
|
|
108
|
+
- If cloudflared is unavailable and ngrok is not configured, Dev Linker prints one-time setup guidance.
|
|
109
|
+
- You may need to set ngrok auth once on your machine using ngrok config add-authtoken <token>.
|
|
110
|
+
- Dev Linker prints a public URL with `ngrok-skip-browser-warning=true` only when ngrok is used.
|
|
111
|
+
- Startup output includes selected tunnel provider (`cloudflare` or `ngrok`).
|
|
112
|
+
- When Dev Linker launches a Vite frontend, it sets `ONELINK=1` to disable Vite HMR WebSockets for stable tunnel behavior.
|
|
113
|
+
|
|
114
|
+
## Real-Time Development Modes
|
|
115
|
+
|
|
116
|
+
### Option 1: Dev Linker sharing mode (recommended)
|
|
117
|
+
|
|
118
|
+
- Run `devlinker` to share one combined frontend/backend URL.
|
|
119
|
+
- Open local Vite URL yourself for instant HMR updates.
|
|
120
|
+
- Share Dev Linker/ngrok URL with others; they can use normal page refresh to see changes.
|
|
121
|
+
|
|
122
|
+
### Option 2: Full remote HMR mode (bypass Dev Linker)
|
|
123
|
+
|
|
124
|
+
- Start frontend and backend manually.
|
|
125
|
+
- Configure Vite `server.proxy` for `/api` to backend.
|
|
126
|
+
- Run `ngrok http <vite-port>` directly so Vite handles WebSocket HMR traffic.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Iterable, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_port(port: int, timeout: float = 1.0) -> bool:
|
|
10
|
+
"""Return True when an HTTP service responds on localhost:port."""
|
|
11
|
+
try:
|
|
12
|
+
response = requests.get(f"http://127.0.0.1:{port}", timeout=timeout)
|
|
13
|
+
return response.status_code < 500
|
|
14
|
+
except requests.RequestException:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_vite_port(port: int, timeout: float = 1.0) -> bool:
|
|
19
|
+
"""Return True when port looks like a Vite dev server."""
|
|
20
|
+
try:
|
|
21
|
+
response = requests.get(f"http://127.0.0.1:{port}/@vite/client", timeout=timeout)
|
|
22
|
+
if response.status_code != 200:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
26
|
+
return "javascript" in content_type or "vite" in response.text[:400].lower()
|
|
27
|
+
except requests.RequestException:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pick_open_port(
|
|
32
|
+
candidates: Iterable[int],
|
|
33
|
+
excluded: Optional[int] = None,
|
|
34
|
+
checker=check_port,
|
|
35
|
+
) -> Optional[int]:
|
|
36
|
+
for port in candidates:
|
|
37
|
+
if excluded is not None and port == excluded:
|
|
38
|
+
continue
|
|
39
|
+
if checker(port):
|
|
40
|
+
return port
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_ports(
|
|
45
|
+
frontend: Optional[int] = None,
|
|
46
|
+
backend: Optional[int] = None,
|
|
47
|
+
retries: int = 12,
|
|
48
|
+
delay_seconds: float = 1.0,
|
|
49
|
+
) -> Tuple[Optional[int], Optional[int]]:
|
|
50
|
+
"""Detect frontend and backend ports with retry support for slow startups."""
|
|
51
|
+
frontend_ports = [5173, 5174, 5175, 5176, 5177, 3000, 8080]
|
|
52
|
+
backend_ports = [5000, 8081]
|
|
53
|
+
|
|
54
|
+
selected_frontend = frontend
|
|
55
|
+
selected_backend = backend
|
|
56
|
+
|
|
57
|
+
for _ in range(max(retries, 1)):
|
|
58
|
+
if selected_frontend is None:
|
|
59
|
+
selected_frontend = _pick_open_port(frontend_ports, checker=is_vite_port)
|
|
60
|
+
if selected_backend is None:
|
|
61
|
+
selected_backend = _pick_open_port(backend_ports, excluded=selected_frontend)
|
|
62
|
+
|
|
63
|
+
if selected_frontend is not None and selected_backend is not None:
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
time.sleep(delay_seconds)
|
|
67
|
+
|
|
68
|
+
return selected_frontend, selected_backend
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .detector import check_port, detect_ports, is_vite_port
|
|
11
|
+
from .proxy import start_proxy
|
|
12
|
+
from .runner import start_servers
|
|
13
|
+
from .tunnel import start_tunnel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_port_in_use(port: int) -> bool:
|
|
17
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
18
|
+
sock.settimeout(1)
|
|
19
|
+
return sock.connect_ex(("127.0.0.1", port)) == 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _select_proxy_port(requested_port: int) -> int:
|
|
23
|
+
if not _is_port_in_use(requested_port):
|
|
24
|
+
return requested_port
|
|
25
|
+
|
|
26
|
+
if requested_port != 8000:
|
|
27
|
+
raise click.ClickException(
|
|
28
|
+
f"Proxy port {requested_port} is already in use. Choose another with --proxy-port."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
for candidate in (8001, 8002, 18000):
|
|
32
|
+
if not _is_port_in_use(candidate):
|
|
33
|
+
print(f"Port 8000 is busy. Falling back to proxy port {candidate}.")
|
|
34
|
+
return candidate
|
|
35
|
+
|
|
36
|
+
raise click.ClickException(
|
|
37
|
+
"No free proxy port found in fallback list (8000, 8001, 8002, 18000)."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _with_ngrok_skip_warning(url: str) -> str:
|
|
42
|
+
parts = urlsplit(url)
|
|
43
|
+
if "ngrok" not in parts.netloc:
|
|
44
|
+
return url
|
|
45
|
+
|
|
46
|
+
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
47
|
+
query["ngrok-skip-browser-warning"] = "true"
|
|
48
|
+
new_query = urlencode(query)
|
|
49
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.command()
|
|
53
|
+
@click.version_option(version=__version__, prog_name="devlinker")
|
|
54
|
+
@click.option("--frontend", type=int, default=None, help="Override detected frontend port.")
|
|
55
|
+
@click.option("--backend", type=int, default=None, help="Override detected backend port.")
|
|
56
|
+
@click.option("--proxy-port", type=int, default=8000, show_default=True, help="Proxy listen port.")
|
|
57
|
+
def cli(frontend: int | None, backend: int | None, proxy_port: int) -> None:
|
|
58
|
+
print(f"\n✨ Dev Linker v{__version__}\n")
|
|
59
|
+
print("🚀 Starting services...")
|
|
60
|
+
|
|
61
|
+
start_servers()
|
|
62
|
+
|
|
63
|
+
print("🔍 Detecting services...")
|
|
64
|
+
frontend_port, backend_port = detect_ports(frontend=frontend, backend=backend)
|
|
65
|
+
|
|
66
|
+
if frontend_port is None:
|
|
67
|
+
raise click.ClickException(
|
|
68
|
+
"Frontend not detected on common ports. Try: devlinker --frontend 5173"
|
|
69
|
+
)
|
|
70
|
+
if backend_port is None:
|
|
71
|
+
raise click.ClickException(
|
|
72
|
+
"Backend not detected on common ports. Try: devlinker --backend 5000"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if not is_vite_port(frontend_port):
|
|
76
|
+
raise click.ClickException(
|
|
77
|
+
f"Frontend port {frontend_port} is reachable but does not look like a Vite dev server. "
|
|
78
|
+
"Run frontend with Dev Linker or pass the correct --frontend port."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not check_port(backend_port):
|
|
82
|
+
raise click.ClickException(
|
|
83
|
+
f"Backend port {backend_port} is not reachable. Try: devlinker --backend 5000"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
proxy_port = _select_proxy_port(proxy_port)
|
|
87
|
+
|
|
88
|
+
print(f" • Frontend -> {frontend_port}")
|
|
89
|
+
print(f" • Backend -> {backend_port}\n")
|
|
90
|
+
|
|
91
|
+
print(f"🌐 Starting proxy on :{proxy_port}...")
|
|
92
|
+
start_proxy(frontend_port, backend_port, proxy_port=proxy_port)
|
|
93
|
+
|
|
94
|
+
# Allow Flask thread to bind before opening tunnel.
|
|
95
|
+
time.sleep(1)
|
|
96
|
+
|
|
97
|
+
print(f"\n🌐 Proxy ready at http://localhost:{proxy_port}\n")
|
|
98
|
+
try:
|
|
99
|
+
print("⚡ Opening public tunnel...")
|
|
100
|
+
provider, public_url = start_tunnel(proxy_port)
|
|
101
|
+
warning_free_url = _with_ngrok_skip_warning(public_url)
|
|
102
|
+
provider_label = "Cloudflare" if provider == "cloudflare" else "ngrok"
|
|
103
|
+
print(f"⚡ Tunnel provider: {provider_label}")
|
|
104
|
+
print("🌍 Public URL:")
|
|
105
|
+
print(f" {warning_free_url}\n")
|
|
106
|
+
print("👉 Share this link with anyone")
|
|
107
|
+
except RuntimeError as exc:
|
|
108
|
+
print(f"⚠ Tunnel unavailable: {exc}")
|
|
109
|
+
print(f"🌐 Local proxy remains available at http://localhost:{proxy_port}")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
while True:
|
|
113
|
+
time.sleep(1)
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
print("\nDev Linker stopped.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
cli()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from flask import Flask, Response, request
|
|
8
|
+
|
|
9
|
+
app = Flask(__name__)
|
|
10
|
+
|
|
11
|
+
FRONTEND: Optional[int] = None
|
|
12
|
+
BACKEND: Optional[int] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _filter_headers(incoming: Dict[str, str]) -> Dict[str, str]:
|
|
16
|
+
excluded = {
|
|
17
|
+
"host",
|
|
18
|
+
"content-length",
|
|
19
|
+
"connection",
|
|
20
|
+
"accept-encoding",
|
|
21
|
+
"upgrade",
|
|
22
|
+
"sec-websocket-key",
|
|
23
|
+
"sec-websocket-version",
|
|
24
|
+
"sec-websocket-extensions",
|
|
25
|
+
}
|
|
26
|
+
return {k: v for k, v in incoming.items() if k.lower() not in excluded}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _forward(target_url: str) -> Response:
|
|
30
|
+
if request.headers.get("Upgrade", "").lower() == "websocket":
|
|
31
|
+
return Response(
|
|
32
|
+
"WebSocket upgrade is not supported by the Dev Linker HTTP proxy. "
|
|
33
|
+
"For shared links, run Vite with HMR disabled.",
|
|
34
|
+
status=426,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
payload = request.get_data() if request.method in {"POST", "PUT", "PATCH"} else None
|
|
38
|
+
query_params = list(request.args.items(multi=True))
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
upstream = requests.request(
|
|
42
|
+
method=request.method,
|
|
43
|
+
url=target_url,
|
|
44
|
+
params=query_params,
|
|
45
|
+
data=payload,
|
|
46
|
+
headers=_filter_headers(dict(request.headers)),
|
|
47
|
+
cookies=request.cookies,
|
|
48
|
+
allow_redirects=False,
|
|
49
|
+
timeout=15,
|
|
50
|
+
)
|
|
51
|
+
except requests.RequestException as exc:
|
|
52
|
+
return Response(f"Upstream unavailable: {exc}", status=502)
|
|
53
|
+
|
|
54
|
+
response = Response(upstream.content, status=upstream.status_code)
|
|
55
|
+
for key, value in upstream.headers.items():
|
|
56
|
+
if key.lower() in {"content-length", "transfer-encoding", "connection"}:
|
|
57
|
+
continue
|
|
58
|
+
response.headers[key] = value
|
|
59
|
+
return response
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.route("/api/<path:path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
|
|
63
|
+
def api_proxy(path: str) -> Response:
|
|
64
|
+
if BACKEND is None:
|
|
65
|
+
return Response("Backend is not configured.", status=503)
|
|
66
|
+
return _forward(f"http://127.0.0.1:{BACKEND}/api/{path}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
|
|
70
|
+
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
|
|
71
|
+
def frontend_proxy(path: str) -> Response:
|
|
72
|
+
if FRONTEND is None:
|
|
73
|
+
return Response("Frontend is not configured.", status=503)
|
|
74
|
+
|
|
75
|
+
target = f"http://127.0.0.1:{FRONTEND}/{path}" if path else f"http://127.0.0.1:{FRONTEND}/"
|
|
76
|
+
return _forward(target)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def start_proxy(frontend_port: int, backend_port: int, proxy_port: int = 8000) -> None:
|
|
80
|
+
global FRONTEND, BACKEND
|
|
81
|
+
FRONTEND = frontend_port
|
|
82
|
+
BACKEND = backend_port
|
|
83
|
+
|
|
84
|
+
thread = threading.Thread(
|
|
85
|
+
target=lambda: app.run(host="0.0.0.0", port=proxy_port, debug=False, use_reloader=False),
|
|
86
|
+
daemon=True,
|
|
87
|
+
)
|
|
88
|
+
thread.start()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from .detector import check_port, is_vite_port
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_package_json(package_path: Path) -> dict:
|
|
15
|
+
try:
|
|
16
|
+
return json.loads(package_path.read_text(encoding="utf-8"))
|
|
17
|
+
except (OSError, json.JSONDecodeError):
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _frontend_command(frontend_dir: Path) -> List[str]:
|
|
22
|
+
package_json = frontend_dir / "package.json"
|
|
23
|
+
data = _read_package_json(package_json)
|
|
24
|
+
scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
|
|
25
|
+
|
|
26
|
+
if "dev" in scripts:
|
|
27
|
+
return ["npm", "run", "dev"]
|
|
28
|
+
if "start" in scripts:
|
|
29
|
+
return ["npm", "start"]
|
|
30
|
+
return ["npm", "run", "dev"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_command(binary: str) -> str:
|
|
34
|
+
if sys.platform.startswith("win") and not binary.lower().endswith(".cmd"):
|
|
35
|
+
win_binary = f"{binary}.cmd"
|
|
36
|
+
resolved = shutil.which(win_binary)
|
|
37
|
+
if resolved:
|
|
38
|
+
return resolved
|
|
39
|
+
|
|
40
|
+
resolved = shutil.which(binary)
|
|
41
|
+
return resolved or binary
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def start_servers(frontend_dir: str = "frontend", backend_dir: str = "backend") -> None:
|
|
45
|
+
"""Launch frontend/backend when their expected directories exist."""
|
|
46
|
+
frontend_path = Path(frontend_dir)
|
|
47
|
+
backend_path = Path(backend_dir)
|
|
48
|
+
|
|
49
|
+
if frontend_path.exists() and frontend_path.is_dir():
|
|
50
|
+
if any(is_vite_port(port, timeout=0.5) for port in (5173, 5174, 5175, 5176, 5177, 3000, 8080)):
|
|
51
|
+
print("[dev-linker] Frontend appears to already be running. Skipping launch.")
|
|
52
|
+
else:
|
|
53
|
+
cmd = _frontend_command(frontend_path)
|
|
54
|
+
cmd[0] = _resolve_command(cmd[0])
|
|
55
|
+
env = os.environ.copy()
|
|
56
|
+
env["ONELINK"] = "1"
|
|
57
|
+
subprocess.Popen(cmd, cwd=frontend_path, env=env) # noqa: S603
|
|
58
|
+
else:
|
|
59
|
+
print("[dev-linker] Skipping frontend launch (frontend/ not found).")
|
|
60
|
+
|
|
61
|
+
app_py = backend_path / "app.py"
|
|
62
|
+
if app_py.exists() and backend_path.is_dir():
|
|
63
|
+
if check_port(5000, timeout=0.5):
|
|
64
|
+
print("[dev-linker] Backend appears to already be running. Skipping launch.")
|
|
65
|
+
else:
|
|
66
|
+
subprocess.Popen([sys.executable, "app.py"], cwd=backend_path) # noqa: S603
|
|
67
|
+
else:
|
|
68
|
+
print("[dev-linker] Skipping backend launch (backend/app.py not found).")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from subprocess import TimeoutExpired
|
|
7
|
+
|
|
8
|
+
from pyngrok import ngrok
|
|
9
|
+
from pyngrok.exception import PyngrokError
|
|
10
|
+
|
|
11
|
+
_TRYCLOUDFLARE_URL = re.compile(r"https://[a-z0-9.-]+\.trycloudflare\.com", re.IGNORECASE)
|
|
12
|
+
_CLOUDFLARED_PROCESSES: list[subprocess.Popen[str]] = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _extract_trycloudflare_url(output: str) -> str | None:
|
|
16
|
+
match = _TRYCLOUDFLARE_URL.search(output)
|
|
17
|
+
return match.group(0) if match else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _try_cloudflare(proxy_port: int, startup_timeout: float = 12.0) -> str | None:
|
|
21
|
+
cloudflared = shutil.which("cloudflared")
|
|
22
|
+
if cloudflared is None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
command = [
|
|
26
|
+
cloudflared,
|
|
27
|
+
"tunnel",
|
|
28
|
+
"--url",
|
|
29
|
+
f"http://127.0.0.1:{proxy_port}",
|
|
30
|
+
"--no-autoupdate",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
process = subprocess.Popen( # noqa: S603
|
|
34
|
+
command,
|
|
35
|
+
stdout=subprocess.PIPE,
|
|
36
|
+
stderr=subprocess.STDOUT,
|
|
37
|
+
text=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
output = ""
|
|
41
|
+
try:
|
|
42
|
+
stdout, _ = process.communicate(timeout=startup_timeout)
|
|
43
|
+
output = stdout or ""
|
|
44
|
+
except TimeoutExpired as exc:
|
|
45
|
+
output = (exc.stdout or "") + (exc.stderr or "")
|
|
46
|
+
|
|
47
|
+
url = _extract_trycloudflare_url(output)
|
|
48
|
+
if url:
|
|
49
|
+
_CLOUDFLARED_PROCESSES.append(process)
|
|
50
|
+
return url
|
|
51
|
+
|
|
52
|
+
process.terminate()
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _disconnect_existing_tunnels() -> None:
|
|
57
|
+
for tunnel in ngrok.get_tunnels():
|
|
58
|
+
ngrok.disconnect(tunnel.public_url)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _start_ngrok_tunnel(proxy_port: int) -> str:
|
|
62
|
+
try:
|
|
63
|
+
tunnel = ngrok.connect(proxy_port)
|
|
64
|
+
return tunnel.public_url
|
|
65
|
+
except PyngrokError as exc:
|
|
66
|
+
message = str(exc).lower()
|
|
67
|
+
|
|
68
|
+
# 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:
|
|
70
|
+
try:
|
|
71
|
+
_disconnect_existing_tunnels()
|
|
72
|
+
tunnel = ngrok.connect(proxy_port)
|
|
73
|
+
return tunnel.public_url
|
|
74
|
+
except PyngrokError as retry_exc:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f"Failed to start ngrok tunnel after disconnect retry: {retry_exc}"
|
|
77
|
+
) from retry_exc
|
|
78
|
+
|
|
79
|
+
if "err_ngrok_108" in message or "simultaneous ngrok agent sessions" in message:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"Failed to start ngrok tunnel: account session limit reached (ERR_NGROK_108). Close other ngrok agents in https://dashboard.ngrok.com/agents or run a single agent with multiple endpoints."
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
if "authtoken" in message or "err_ngrok_4018" in message or "authentication failed" in message:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"Failed to start ngrok tunnel: missing or invalid auth token. Run 'ngrok config add-authtoken <token>' and try again."
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
raise RuntimeError(f"Failed to start ngrok tunnel: {exc}") from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def start_tunnel(proxy_port: int = 8000) -> tuple[str, str]:
|
|
93
|
+
"""Open a public tunnel and return (provider, url)."""
|
|
94
|
+
cloudflare_url = _try_cloudflare(proxy_port)
|
|
95
|
+
if cloudflare_url is not None:
|
|
96
|
+
return "cloudflare", cloudflare_url
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
ngrok_url = _start_ngrok_tunnel(proxy_port)
|
|
100
|
+
return "ngrok", ngrok_url
|
|
101
|
+
except RuntimeError as ngrok_error:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
"No tunnel available.\n"
|
|
104
|
+
"Option 1 (recommended): install cloudflared and ensure it is on PATH.\n"
|
|
105
|
+
"Option 2: configure ngrok auth token with 'ngrok config add-authtoken <token>'.\n"
|
|
106
|
+
f"Ngrok details: {ngrok_error}"
|
|
107
|
+
) from ngrok_error
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devlinker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered linking and automation tool
|
|
5
|
+
Author-email: Mani <mani1028@users.noreply.github.com>
|
|
6
|
+
Requires-Python: >=3.7
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: flask
|
|
10
|
+
Requires-Dist: pyngrok
|
|
11
|
+
Requires-Dist: requests
|
|
12
|
+
|
|
13
|
+
# Dev Linker
|
|
14
|
+
|
|
15
|
+
Dev Linker runs frontend and backend dev servers, proxies both through a single local port (8000), and creates a single public URL via Cloudflare or ngrok.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Launches frontend and backend (when frontend and backend/app.py exist)
|
|
20
|
+
- Detects common frontend/backend ports
|
|
21
|
+
- Serves both through one proxy at http://localhost:8000
|
|
22
|
+
- Creates a public tunnel for sharing (Cloudflare first, ngrok fallback)
|
|
23
|
+
- Terminal-first workflow
|
|
24
|
+
- Supports CLI version output with --version
|
|
25
|
+
|
|
26
|
+
## Project Structure
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
onelink/
|
|
30
|
+
├── onelink/
|
|
31
|
+
│ ├── __init__.py
|
|
32
|
+
│ ├── main.py
|
|
33
|
+
│ ├── runner.py
|
|
34
|
+
│ ├── detector.py
|
|
35
|
+
│ ├── proxy.py
|
|
36
|
+
│ └── tunnel.py
|
|
37
|
+
├── setup.py
|
|
38
|
+
├── README.md
|
|
39
|
+
└── requirements.txt
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
For local development:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
After publishing to PyPI:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install dev-linker
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Run
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
devlinker
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Typical startup output:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
✨ Dev Linker v0.1.0
|
|
66
|
+
|
|
67
|
+
🚀 Starting services...
|
|
68
|
+
🔍 Detecting services...
|
|
69
|
+
• Frontend -> 5173
|
|
70
|
+
• Backend -> 5000
|
|
71
|
+
|
|
72
|
+
🌐 Proxy ready at http://localhost:8000
|
|
73
|
+
|
|
74
|
+
⚡ Tunnel provider: Cloudflare
|
|
75
|
+
🌍 Public URL:
|
|
76
|
+
https://xxxx.trycloudflare.com
|
|
77
|
+
|
|
78
|
+
👉 Share this link with anyone
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Version check:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
devlinker --version
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Optional overrides:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
devlinker --frontend 5173 --backend 5000
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If port 8000 is already in use:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
devlinker --frontend 5173 --backend 5000 --proxy-port 18000
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Default behavior also tries fallback ports automatically when 8000 is busy:
|
|
100
|
+
|
|
101
|
+
- 8001
|
|
102
|
+
- 8002
|
|
103
|
+
- 18000
|
|
104
|
+
|
|
105
|
+
## Important Frontend Rule
|
|
106
|
+
|
|
107
|
+
Frontend requests must use relative API paths:
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
fetch("/api/endpoint")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Do not hardcode backend host URLs in frontend code.
|
|
114
|
+
|
|
115
|
+
## Notes
|
|
116
|
+
|
|
117
|
+
- runner.py expects frontend project in frontend and Flask app in backend/app.py.
|
|
118
|
+
- If those paths do not exist, Dev Linker skips launch and only tries to detect already-running services.
|
|
119
|
+
- Tunnel selection order is: cloudflared (TryCloudflare), then ngrok.
|
|
120
|
+
- If cloudflared is unavailable and ngrok is not configured, Dev Linker prints one-time setup guidance.
|
|
121
|
+
- You may need to set ngrok auth once on your machine using ngrok config add-authtoken <token>.
|
|
122
|
+
- Dev Linker prints a public URL with `ngrok-skip-browser-warning=true` only when ngrok is used.
|
|
123
|
+
- Startup output includes selected tunnel provider (`cloudflare` or `ngrok`).
|
|
124
|
+
- When Dev Linker launches a Vite frontend, it sets `ONELINK=1` to disable Vite HMR WebSockets for stable tunnel behavior.
|
|
125
|
+
|
|
126
|
+
## Real-Time Development Modes
|
|
127
|
+
|
|
128
|
+
### Option 1: Dev Linker sharing mode (recommended)
|
|
129
|
+
|
|
130
|
+
- Run `devlinker` to share one combined frontend/backend URL.
|
|
131
|
+
- Open local Vite URL yourself for instant HMR updates.
|
|
132
|
+
- Share Dev Linker/ngrok URL with others; they can use normal page refresh to see changes.
|
|
133
|
+
|
|
134
|
+
### Option 2: Full remote HMR mode (bypass Dev Linker)
|
|
135
|
+
|
|
136
|
+
- Start frontend and backend manually.
|
|
137
|
+
- Configure Vite `server.proxy` for `/api` to backend.
|
|
138
|
+
- Run `ngrok http <vite-port>` directly so Vite handles WebSocket HMR traffic.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
devlinker/__init__.py
|
|
5
|
+
devlinker/detector.py
|
|
6
|
+
devlinker/main.py
|
|
7
|
+
devlinker/proxy.py
|
|
8
|
+
devlinker/runner.py
|
|
9
|
+
devlinker/tunnel.py
|
|
10
|
+
devlinker.egg-info/PKG-INFO
|
|
11
|
+
devlinker.egg-info/SOURCES.txt
|
|
12
|
+
devlinker.egg-info/dependency_links.txt
|
|
13
|
+
devlinker.egg-info/entry_points.txt
|
|
14
|
+
devlinker.egg-info/requires.txt
|
|
15
|
+
devlinker.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devlinker
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devlinker"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AI-powered linking and automation tool"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Mani", email = "mani1028@users.noreply.github.com" }
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.7"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"click",
|
|
16
|
+
"flask",
|
|
17
|
+
"pyngrok",
|
|
18
|
+
"requests",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
devlinker = "devlinker.main:cli"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
include = ["devlinker*"]
|
devlinker-0.1.0/setup.py
ADDED