devlinker 1.2.1__tar.gz → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.2.1
3
+ Version: 1.2.3
4
4
  Summary: AI-powered linking and automation tool
5
5
  Author-email: Mani <mani1028@users.noreply.github.com>
6
6
  Requires-Python: >=3.7
@@ -20,6 +20,7 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
20
20
  - Auto-detects backend runtime (Docker Compose, Dockerfile, Node, or Python)
21
21
  - Auto-starts Python/Node backends; Docker is manual by default for reliability
22
22
  - Detects common frontend/backend ports
23
+ - Detects Vite frontend across dynamic fallback ports (5173-5190, plus common alternatives)
23
24
  - Supports Docker backend port auto-detection
24
25
  - Works with dynamic container host ports
25
26
  - No config needed for standard Flask/Docker flows
@@ -67,7 +68,7 @@ devlinker
67
68
  Typical startup output:
68
69
 
69
70
  ```text
70
- Dev Linker v0.2.0
71
+ Dev Linker v1.2.2
71
72
 
72
73
  [INFO] Mode: Auto (Flask + Docker detection)
73
74
  [INFO] Booting local services...
@@ -122,6 +123,18 @@ Run local-only mode without tunnel:
122
123
  devlinker --no-tunnel
123
124
  ```
124
125
 
126
+ Interactive backend selection (when local and Docker are both detected):
127
+
128
+ ```bash
129
+ devlinker --interactive-backend
130
+ ```
131
+
132
+ Disable interactive backend selection (keeps local-first behavior):
133
+
134
+ ```bash
135
+ devlinker --no-interactive-backend
136
+ ```
137
+
125
138
  If port 8000 is already in use:
126
139
 
127
140
  ```bash
@@ -139,6 +152,12 @@ Default behavior also tries fallback ports automatically when 8000 is busy:
139
152
  - 8002
140
153
  - 18000
141
154
 
155
+ Frontend detection behavior:
156
+
157
+ - Scans Vite defaults and fallback ports (`5173` through `5190`)
158
+ - Also checks common alternatives (`3000`, `4173`, `8080`)
159
+ - Retries during startup to catch slow boot cases
160
+
142
161
  ## Important Frontend Rule
143
162
 
144
163
  Frontend requests must use relative API paths:
@@ -154,9 +173,14 @@ Do not hardcode backend host URLs in frontend code.
154
173
  Backend port detection runs in this order:
155
174
 
156
175
  1. Check localhost port 5000
157
- 2. If not found, check Docker port mappings for `->5000/tcp`
158
- 3. Use the mapped host port automatically
159
- 4. If nothing is found, print next-step guidance and exit
176
+ 2. If not found, parse all Docker host-to-container port mappings
177
+ 3. Rank containers by likely backend identity (name hints like backend/api plus project-name hints)
178
+ 4. Use the best mapped host port automatically, even when internal port is not 5000
179
+ 5. If nothing is found, print next-step guidance and exit
180
+
181
+ When both Local and Docker backends are available, Dev Linker prompts you to choose one (TTY mode) unless `--no-interactive-backend` is used.
182
+
183
+ If backend detection fails, Dev Linker prints a clear checklist showing what it checked and how to recover.
160
184
 
161
185
  Detection messages include source labels, for example:
162
186
 
@@ -8,6 +8,7 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
8
8
  - Auto-detects backend runtime (Docker Compose, Dockerfile, Node, or Python)
9
9
  - Auto-starts Python/Node backends; Docker is manual by default for reliability
10
10
  - Detects common frontend/backend ports
11
+ - Detects Vite frontend across dynamic fallback ports (5173-5190, plus common alternatives)
11
12
  - Supports Docker backend port auto-detection
12
13
  - Works with dynamic container host ports
13
14
  - No config needed for standard Flask/Docker flows
@@ -55,7 +56,7 @@ devlinker
55
56
  Typical startup output:
56
57
 
57
58
  ```text
58
- Dev Linker v0.2.0
59
+ Dev Linker v1.2.2
59
60
 
60
61
  [INFO] Mode: Auto (Flask + Docker detection)
61
62
  [INFO] Booting local services...
@@ -110,6 +111,18 @@ Run local-only mode without tunnel:
110
111
  devlinker --no-tunnel
111
112
  ```
112
113
 
114
+ Interactive backend selection (when local and Docker are both detected):
115
+
116
+ ```bash
117
+ devlinker --interactive-backend
118
+ ```
119
+
120
+ Disable interactive backend selection (keeps local-first behavior):
121
+
122
+ ```bash
123
+ devlinker --no-interactive-backend
124
+ ```
125
+
113
126
  If port 8000 is already in use:
114
127
 
115
128
  ```bash
@@ -127,6 +140,12 @@ Default behavior also tries fallback ports automatically when 8000 is busy:
127
140
  - 8002
128
141
  - 18000
129
142
 
143
+ Frontend detection behavior:
144
+
145
+ - Scans Vite defaults and fallback ports (`5173` through `5190`)
146
+ - Also checks common alternatives (`3000`, `4173`, `8080`)
147
+ - Retries during startup to catch slow boot cases
148
+
130
149
  ## Important Frontend Rule
131
150
 
132
151
  Frontend requests must use relative API paths:
@@ -142,9 +161,14 @@ Do not hardcode backend host URLs in frontend code.
142
161
  Backend port detection runs in this order:
143
162
 
144
163
  1. Check localhost port 5000
145
- 2. If not found, check Docker port mappings for `->5000/tcp`
146
- 3. Use the mapped host port automatically
147
- 4. If nothing is found, print next-step guidance and exit
164
+ 2. If not found, parse all Docker host-to-container port mappings
165
+ 3. Rank containers by likely backend identity (name hints like backend/api plus project-name hints)
166
+ 4. Use the best mapped host port automatically, even when internal port is not 5000
167
+ 5. If nothing is found, print next-step guidance and exit
168
+
169
+ When both Local and Docker backends are available, Dev Linker prompts you to choose one (TTY mode) unless `--no-interactive-backend` is used.
170
+
171
+ If backend detection fails, Dev Linker prints a clear checklist showing what it checked and how to recover.
148
172
 
149
173
  Detection messages include source labels, for example:
150
174
 
@@ -41,6 +41,18 @@ def _pick_open_port(
41
41
  return None
42
42
 
43
43
 
44
+ def _ordered_unique_ports(*port_groups: Iterable[int]) -> list[int]:
45
+ ordered: list[int] = []
46
+ seen: set[int] = set()
47
+ for group in port_groups:
48
+ for port in group:
49
+ if port in seen:
50
+ continue
51
+ seen.add(port)
52
+ ordered.append(port)
53
+ return ordered
54
+
55
+
44
56
  def detect_ports(
45
57
  frontend: Optional[int] = None,
46
58
  backend: Optional[int] = None,
@@ -48,8 +60,13 @@ def detect_ports(
48
60
  delay_seconds: float = 1.0,
49
61
  ) -> Tuple[Optional[int], Optional[int]]:
50
62
  """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]
63
+ frontend_ports = _ordered_unique_ports(
64
+ range(5173, 5191),
65
+ (3000, 4173, 8080),
66
+ )
67
+ backend_ports = _ordered_unique_ports(
68
+ (5000, 8000, 8001, 8080, 8081, 3001),
69
+ )
53
70
 
54
71
  selected_frontend = frontend
55
72
  selected_backend = backend
@@ -87,6 +87,12 @@ def _print_summary(
87
87
  help="Auto-start Docker backends (manual Docker is the default).",
88
88
  )
89
89
  @click.option("--no-tunnel", is_flag=True, help="Skip public tunnel and run local proxy only.")
90
+ @click.option(
91
+ "--interactive-backend/--no-interactive-backend",
92
+ default=True,
93
+ show_default=True,
94
+ help="Prompt to choose backend when local and Docker candidates are both available.",
95
+ )
90
96
  @click.option("--debug", is_flag=True, hidden=True, help="Enable debug logging.")
91
97
  def cli(
92
98
  frontend: int | None,
@@ -94,6 +100,7 @@ def cli(
94
100
  proxy_port: int,
95
101
  auto_start_docker: bool,
96
102
  no_tunnel: bool,
103
+ interactive_backend: bool,
97
104
  debug: bool,
98
105
  ) -> None:
99
106
  started = time.perf_counter()
@@ -106,6 +113,7 @@ def cli(
106
113
  backend_port = detect_backend_port(
107
114
  default_port=5000,
108
115
  override_port=backend_port_override,
116
+ interactive=interactive_backend,
109
117
  debug=debug,
110
118
  )
111
119
  if backend_port is None:
@@ -36,34 +36,71 @@ def is_port_open(port: int) -> bool:
36
36
  return sock.connect_ex(("localhost", port)) == 0
37
37
 
38
38
 
39
- def _extract_host_port(ports_text: str, container_port: int) -> int | None:
40
- # Covers typical Docker mappings: 0.0.0.0:32768->5000/tcp, [::]:32768->5000/tcp
41
- pattern = rf"(?:0\.0\.0\.0|127\.0\.0\.1|\[::\]|::):(\d+)->{container_port}/tcp"
42
- match = re.search(pattern, ports_text)
43
- if match:
44
- return int(match.group(1))
45
- return None
39
+ def _extract_port_mappings(ports_text: str) -> list[tuple[int, int]]:
40
+ """
41
+ Extracts host and container ports from docker ps output.
42
+ Handles:
43
+ - 0.0.0.0:8000->8000/tcp
44
+ - [::]:8000->8000/tcp
45
+ - :::8000->8000/tcp
46
+ - 8000->8000/tcp
47
+
48
+ Skips unmapped ports (e.g., "8000/tcp" without a host binding).
49
+ """
50
+ # Skip if no host mappings present (unmapped ports are not useful externally).
51
+ if "->" not in ports_text:
52
+ return []
53
+
54
+ # This regex specifically captures the host port (Group 1) and container port (Group 2)
55
+ # while optionally ignoring any IP binding prefix.
56
+ pattern = re.compile(
57
+ r"(?:(?:\d{1,3}(?:\.\d{1,3}){3}|\[[a-fA-F0-9:]+\]|:::):)?(\d+)->(\d+)/(?:tcp|udp)"
58
+ )
59
+
60
+ mappings: list[tuple[int, int]] = []
61
+ for match in pattern.finditer(ports_text):
62
+ host_port = int(match.group(1))
63
+ container_port = int(match.group(2))
64
+ mappings.append((host_port, container_port))
65
+ return mappings
66
+
67
+
68
+ def _container_priority(name: str, container_port: int, default_container_port: int) -> int:
69
+ score = 0
70
+ lowered_name = name.lower()
71
+ if "backend" in lowered_name:
72
+ score += 6
73
+ if any(token in lowered_name for token in ("api", "server", "svc", "service")):
74
+ score += 3
75
+ if container_port == default_container_port:
76
+ score += 2
77
+
78
+ project_hint = Path.cwd().name.lower()
79
+ if len(project_hint) >= 3 and project_hint in lowered_name:
80
+ score += 5
46
81
 
82
+ return score
47
83
 
48
- def get_docker_backend_port(
84
+
85
+ def get_docker_backend_candidates(
49
86
  default_container_port: int = 5000,
50
87
  debug: bool = False,
51
- ) -> tuple[int, str, int] | None:
88
+ ) -> list[tuple[str, int, int]]:
52
89
  try:
53
90
  output = subprocess.check_output( # noqa: S603
54
91
  ["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"],
55
92
  stderr=subprocess.DEVNULL,
56
93
  ).decode("utf-8", errors="ignore")
57
94
  except Exception:
58
- return None
95
+ return []
59
96
 
60
97
  _debug_log(debug, "docker ps port map output:")
61
98
  if debug:
62
99
  for raw_line in output.splitlines():
63
100
  _debug_log(True, raw_line)
64
101
 
65
- candidates: list[tuple[str, int]] = []
66
- for line in output.splitlines():
102
+ candidates: list[tuple[str, int, int, int]] = []
103
+ for line_index, line in enumerate(output.splitlines()):
67
104
  stripped = line.strip()
68
105
  if not stripped:
69
106
  continue
@@ -76,23 +113,86 @@ def get_docker_backend_port(
76
113
  continue
77
114
  name, ports = parts[0], parts[1]
78
115
 
79
- host_port = _extract_host_port(ports, default_container_port)
80
- if host_port is not None:
81
- candidates.append((name, host_port))
116
+ mappings = _extract_port_mappings(ports)
117
+ if not mappings:
118
+ continue
119
+
120
+ for host_port, container_port in mappings:
121
+ candidates.append((name, host_port, container_port, line_index))
122
+ _debug_log(
123
+ debug,
124
+ (
125
+ f"Candidate Docker port mapping: container='{name}', "
126
+ f"host={host_port}, container={container_port}"
127
+ ),
128
+ )
82
129
 
83
130
  if not candidates:
84
- return None
131
+ return []
132
+
133
+ # Score candidates by likely backend identity and prefer newest on ties.
134
+ ranked = sorted(
135
+ candidates,
136
+ key=lambda item: (-_container_priority(item[0], item[2], default_container_port), item[3]),
137
+ )
138
+ ordered: list[tuple[str, int, int]] = []
139
+ seen: set[tuple[str, int, int]] = set()
140
+ for name, host_port, container_port, _line_index in ranked:
141
+ key = (name, host_port, container_port)
142
+ if key in seen:
143
+ continue
144
+ seen.add(key)
145
+ ordered.append(key)
146
+
147
+ if ordered:
148
+ name, host_port, container_port = ordered[0]
149
+ _debug_log(
150
+ debug,
151
+ (
152
+ f"Selected Docker container '{name}' with host port {host_port} "
153
+ f"(container port {container_port})"
154
+ ),
155
+ )
156
+
157
+ return ordered
85
158
 
86
- # Prefer container names that look like backend services.
87
- for name, port in candidates:
88
- if "backend" in name.lower():
89
- _debug_log(debug, f"Selected Docker backend container '{name}' on host port {port}")
90
- return port, name, len(candidates)
91
159
 
92
- # docker ps is already newest-first; fallback to first match.
93
- name, port = candidates[0]
94
- _debug_log(debug, f"Selected first Docker match '{name}' on host port {port}")
95
- return port, name, len(candidates)
160
+ def _choose_backend_candidate(
161
+ local_port: int,
162
+ docker_candidates: list[tuple[str, int, int]],
163
+ debug: bool = False,
164
+ ) -> tuple[str, int, str | None, int | None]:
165
+ print("[INFO] Multiple backends detected:")
166
+ print(f"1. Local (localhost:{local_port})")
167
+ for index, (container_name, host_port, container_port) in enumerate(docker_candidates, start=2):
168
+ print(f"{index}. Docker: {container_name} (localhost:{host_port} -> {container_port})")
169
+ print("[INFO] Select backend [default: 1]: ", end="", flush=True)
170
+
171
+ try:
172
+ raw = input().strip()
173
+ except EOFError:
174
+ _debug_log(debug, "Interactive prompt unavailable (EOF); using local backend fallback")
175
+ return "local", local_port, None, None
176
+
177
+ if raw == "":
178
+ return "local", local_port, None, None
179
+
180
+ try:
181
+ selection = int(raw)
182
+ except ValueError:
183
+ _log("warn", "Invalid selection; using local backend")
184
+ return "local", local_port, None, None
185
+
186
+ if selection == 1:
187
+ return "local", local_port, None, None
188
+
189
+ docker_index = selection - 2
190
+ if 0 <= docker_index < len(docker_candidates):
191
+ container_name, host_port, container_port = docker_candidates[docker_index]
192
+ return "docker", host_port, container_name, container_port
193
+
194
+ _log("warn", "Selection out of range; using local backend")
195
+ return "local", local_port, None, None
96
196
 
97
197
 
98
198
  def _wait_for_port(port: int, retries: int = 5, delay_seconds: float = 1.0, debug: bool = False) -> bool:
@@ -107,6 +207,7 @@ def _wait_for_port(port: int, retries: int = 5, delay_seconds: float = 1.0, debu
107
207
  def detect_backend_port(
108
208
  default_port: int = 5000,
109
209
  override_port: int | None = None,
210
+ interactive: bool = False,
110
211
  debug: bool = False,
111
212
  ) -> int | None:
112
213
  started = time.perf_counter()
@@ -118,32 +219,63 @@ def detect_backend_port(
118
219
 
119
220
  _log("info", "Checking backend...")
120
221
  _debug_log(debug, f"Scanned local port: {default_port}")
121
- if is_port_open(default_port):
122
- _log("ok", f"Backend detected (Local) -> port {default_port}")
123
- _log("info", f"Backend detected in {time.perf_counter() - started:.1f}s")
124
- return default_port
125
222
 
126
- _log("warn", f"Backend not found on port {default_port}")
223
+ local_open = is_port_open(default_port)
224
+ if not local_open:
225
+ _log("warn", f"Backend not found on port {default_port}")
226
+
127
227
  _log("info", "Checking Docker containers...")
128
228
  _debug_log(debug, f"Scanned Docker container target port: {default_port}")
129
229
 
130
- docker_match = get_docker_backend_port(default_container_port=default_port, debug=debug)
131
- if docker_match is not None:
132
- docker_port, container_name, match_count = docker_match
133
- if match_count > 1:
134
- _log("warn", "Multiple backend containers found")
230
+ docker_candidates = get_docker_backend_candidates(default_container_port=default_port, debug=debug)
231
+
232
+ if local_open and docker_candidates:
233
+ if interactive and sys.stdin.isatty():
234
+ source, selected_port, container_name, container_port = _choose_backend_candidate(
235
+ local_port=default_port,
236
+ docker_candidates=docker_candidates,
237
+ debug=debug,
238
+ )
239
+ if source == "local":
240
+ _log("ok", f"Backend detected (Local) -> port {selected_port}")
241
+ _log("info", f"Backend detected in {time.perf_counter() - started:.1f}s")
242
+ return selected_port
243
+
244
+ if _wait_for_port(selected_port, retries=5, delay_seconds=1.0, debug=debug):
245
+ _log("ok", f"Backend detected (Docker: {container_name}) -> port {selected_port}")
246
+ _debug_log(debug, f"Selected Docker container port: {container_port}")
247
+ _log("info", f"Backend detected in {time.perf_counter() - started:.1f}s")
248
+ return selected_port
249
+ _log("warn", f"Docker mapped port {selected_port} found but backend is not ready yet")
250
+ else:
251
+ _log("info", f"Docker candidate also detected: {docker_candidates[0][0]} -> {docker_candidates[0][1]}")
252
+ _debug_log(debug, "Interactive backend picker disabled or no TTY; using local-first priority")
253
+ _log("ok", f"Backend detected (Local) -> port {default_port}")
254
+ _log("info", f"Backend detected in {time.perf_counter() - started:.1f}s")
255
+ return default_port
256
+
257
+ if local_open:
258
+ _log("ok", f"Backend detected (Local) -> port {default_port}")
259
+ _log("info", f"Backend detected in {time.perf_counter() - started:.1f}s")
260
+ return default_port
261
+
262
+ if docker_candidates:
263
+ container_name, docker_port, container_port = docker_candidates[0]
264
+ if len(docker_candidates) > 1:
265
+ _log("warn", f"Multiple Docker containers published ports ({len(docker_candidates)} candidates)")
135
266
  _log("info", f"Using: {container_name}")
136
267
  if _wait_for_port(docker_port, retries=5, delay_seconds=1.0, debug=debug):
137
- _log("ok", f"Backend detected (Docker) -> port {docker_port}")
268
+ _log("ok", f"Backend detected (Docker: {container_name}) -> port {docker_port}")
269
+ _debug_log(debug, f"Selected Docker container port: {container_port}")
138
270
  _log("info", f"Backend detected in {time.perf_counter() - started:.1f}s")
139
271
  return docker_port
140
272
  _log("warn", f"Docker mapped port {docker_port} found but backend is not ready yet")
141
273
 
142
- _log("error", "Backend not detected")
274
+ _log("error", "No backend found")
143
275
  print("Checked:")
144
276
  print(f"- localhost:{default_port}")
145
277
  print("- Docker containers")
146
- print("Next step:")
278
+ print("Tip:")
147
279
  print(" - Start Flask: python app.py")
148
280
  print(" - OR expose Docker port: -p 5000:5000")
149
281
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.2.1
3
+ Version: 1.2.3
4
4
  Summary: AI-powered linking and automation tool
5
5
  Author-email: Mani <mani1028@users.noreply.github.com>
6
6
  Requires-Python: >=3.7
@@ -20,6 +20,7 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
20
20
  - Auto-detects backend runtime (Docker Compose, Dockerfile, Node, or Python)
21
21
  - Auto-starts Python/Node backends; Docker is manual by default for reliability
22
22
  - Detects common frontend/backend ports
23
+ - Detects Vite frontend across dynamic fallback ports (5173-5190, plus common alternatives)
23
24
  - Supports Docker backend port auto-detection
24
25
  - Works with dynamic container host ports
25
26
  - No config needed for standard Flask/Docker flows
@@ -67,7 +68,7 @@ devlinker
67
68
  Typical startup output:
68
69
 
69
70
  ```text
70
- Dev Linker v0.2.0
71
+ Dev Linker v1.2.2
71
72
 
72
73
  [INFO] Mode: Auto (Flask + Docker detection)
73
74
  [INFO] Booting local services...
@@ -122,6 +123,18 @@ Run local-only mode without tunnel:
122
123
  devlinker --no-tunnel
123
124
  ```
124
125
 
126
+ Interactive backend selection (when local and Docker are both detected):
127
+
128
+ ```bash
129
+ devlinker --interactive-backend
130
+ ```
131
+
132
+ Disable interactive backend selection (keeps local-first behavior):
133
+
134
+ ```bash
135
+ devlinker --no-interactive-backend
136
+ ```
137
+
125
138
  If port 8000 is already in use:
126
139
 
127
140
  ```bash
@@ -139,6 +152,12 @@ Default behavior also tries fallback ports automatically when 8000 is busy:
139
152
  - 8002
140
153
  - 18000
141
154
 
155
+ Frontend detection behavior:
156
+
157
+ - Scans Vite defaults and fallback ports (`5173` through `5190`)
158
+ - Also checks common alternatives (`3000`, `4173`, `8080`)
159
+ - Retries during startup to catch slow boot cases
160
+
142
161
  ## Important Frontend Rule
143
162
 
144
163
  Frontend requests must use relative API paths:
@@ -154,9 +173,14 @@ Do not hardcode backend host URLs in frontend code.
154
173
  Backend port detection runs in this order:
155
174
 
156
175
  1. Check localhost port 5000
157
- 2. If not found, check Docker port mappings for `->5000/tcp`
158
- 3. Use the mapped host port automatically
159
- 4. If nothing is found, print next-step guidance and exit
176
+ 2. If not found, parse all Docker host-to-container port mappings
177
+ 3. Rank containers by likely backend identity (name hints like backend/api plus project-name hints)
178
+ 4. Use the best mapped host port automatically, even when internal port is not 5000
179
+ 5. If nothing is found, print next-step guidance and exit
180
+
181
+ When both Local and Docker backends are available, Dev Linker prompts you to choose one (TTY mode) unless `--no-interactive-backend` is used.
182
+
183
+ If backend detection fails, Dev Linker prints a clear checklist showing what it checked and how to recover.
160
184
 
161
185
  Detection messages include source labels, for example:
162
186
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlinker"
7
- version = "1.2.1"
7
+ version = "1.2.3"
8
8
  description = "AI-powered linking and automation tool"
9
9
  authors = [
10
10
  { name = "Mani", email = "mani1028@users.noreply.github.com" }
File without changes
File without changes
File without changes
File without changes