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.
@@ -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,11 @@
1
+ """Dev Linker package."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = [
6
+ "main",
7
+ "runner",
8
+ "detector",
9
+ "proxy",
10
+ "tunnel",
11
+ ]
@@ -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,2 @@
1
+ [console_scripts]
2
+ devlinker = devlinker.main:cli
@@ -0,0 +1,4 @@
1
+ click
2
+ flask
3
+ pyngrok
4
+ requests
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup()