devlauncher 0.1.0__py3-none-any.whl
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.
- devlauncher/__init__.py +1 -0
- devlauncher/cli.py +165 -0
- devlauncher/config.py +115 -0
- devlauncher/discovery.py +512 -0
- devlauncher/installer.py +99 -0
- devlauncher/ports.py +30 -0
- devlauncher/runner.py +117 -0
- devlauncher-0.1.0.dist-info/METADATA +130 -0
- devlauncher-0.1.0.dist-info/RECORD +13 -0
- devlauncher-0.1.0.dist-info/WHEEL +5 -0
- devlauncher-0.1.0.dist-info/entry_points.txt +2 -0
- devlauncher-0.1.0.dist-info/licenses/LICENSE +21 -0
- devlauncher-0.1.0.dist-info/top_level.txt +1 -0
devlauncher/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
devlauncher/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""devlauncher CLI entry point.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
devlauncher # reads dev.toml, or auto-discovers services
|
|
5
|
+
devlauncher path/to/dev.toml
|
|
6
|
+
python -m devlauncher
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List
|
|
13
|
+
|
|
14
|
+
from .config import Service, load_config, resolve_port_refs
|
|
15
|
+
from .discovery import discover_services, services_to_toml
|
|
16
|
+
from .installer import needs_install, run_install
|
|
17
|
+
from .ports import find_free_port
|
|
18
|
+
from .runner import BOLD, RESET, YELLOW, _PALETTE, run_services
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_install_phase(services: List[Service]) -> None:
|
|
22
|
+
"""Run dependency installs for any service that needs them.
|
|
23
|
+
|
|
24
|
+
Installs run sequentially (not in parallel) so output stays readable.
|
|
25
|
+
A non-zero exit code from an install command warns but does not abort —
|
|
26
|
+
services may still start successfully even with partial install failures.
|
|
27
|
+
"""
|
|
28
|
+
needs = [svc for svc in services if needs_install(svc)]
|
|
29
|
+
to_install = [
|
|
30
|
+
(svc, _PALETTE[i % len(_PALETTE)])
|
|
31
|
+
for i, svc in enumerate(needs)
|
|
32
|
+
]
|
|
33
|
+
if not to_install:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
print(f"{BOLD}Installing dependencies...{RESET}\n")
|
|
37
|
+
for svc, color in to_install:
|
|
38
|
+
exit_code = run_install(svc, color=color)
|
|
39
|
+
if exit_code != 0:
|
|
40
|
+
print(
|
|
41
|
+
f"{YELLOW}⚠ install_cmd for '{svc.name}' exited {exit_code} "
|
|
42
|
+
f"— continuing anyway{RESET}"
|
|
43
|
+
)
|
|
44
|
+
print()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_services(services: List[Service]) -> List[Service]:
|
|
48
|
+
"""Detect port conflicts, notify user, return services with actual ports."""
|
|
49
|
+
resolved_ports: dict[str, int] = {}
|
|
50
|
+
final: List[Service] = []
|
|
51
|
+
|
|
52
|
+
for svc in services:
|
|
53
|
+
actual = find_free_port(svc.port)
|
|
54
|
+
resolved_ports[svc.name] = actual
|
|
55
|
+
if actual != svc.port:
|
|
56
|
+
print(
|
|
57
|
+
f"{YELLOW}⚠ Port {svc.port} in use → using {actual} "
|
|
58
|
+
f"for {svc.name}{RESET}"
|
|
59
|
+
)
|
|
60
|
+
final.append(replace(svc, port=actual))
|
|
61
|
+
|
|
62
|
+
# Resolve {name.port} and {self.port} references now that all ports are known
|
|
63
|
+
return resolve_port_refs(final, resolved_ports)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main() -> None:
|
|
67
|
+
explicit_config = len(sys.argv) > 1
|
|
68
|
+
config_path = sys.argv[1] if explicit_config else "dev.toml"
|
|
69
|
+
|
|
70
|
+
services: List[Service] = []
|
|
71
|
+
|
|
72
|
+
if Path(config_path).exists():
|
|
73
|
+
# dev.toml present — use it
|
|
74
|
+
try:
|
|
75
|
+
services = load_config(config_path)
|
|
76
|
+
except (ValueError, Exception) as e:
|
|
77
|
+
print(f"Config error: {e}", file=sys.stderr)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
elif explicit_config:
|
|
81
|
+
# User passed a path that doesn't exist
|
|
82
|
+
print(f"Error: config file not found: {config_path}", file=sys.stderr)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
else:
|
|
86
|
+
# No dev.toml — run auto-discovery once, confirm, then save
|
|
87
|
+
print(f"{YELLOW}No dev.toml found — running auto-discovery...{RESET}\n")
|
|
88
|
+
services, warnings = discover_services()
|
|
89
|
+
|
|
90
|
+
for w in warnings:
|
|
91
|
+
print(f"{YELLOW}⚠ {w}{RESET}")
|
|
92
|
+
|
|
93
|
+
if not services:
|
|
94
|
+
print(
|
|
95
|
+
"\nNo services detected. Create a dev.toml to define your services.\n"
|
|
96
|
+
" Example:\n"
|
|
97
|
+
" [services.api]\n"
|
|
98
|
+
' cmd = "uvicorn main:app --reload --port {self.port}"\n'
|
|
99
|
+
" port = 8000\n"
|
|
100
|
+
' cwd = "api"\n',
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
# ── Discovery report ───────────────────────────────────────────────────
|
|
106
|
+
_FW_WIDTH = 10
|
|
107
|
+
_DIR_WIDTH = 14
|
|
108
|
+
print(f" {'SERVICE':<8} {'FRAMEWORK':<{_FW_WIDTH}} {'DIR':<{_DIR_WIDTH}} COMMAND")
|
|
109
|
+
print(f" {'─'*8} {'─'*_FW_WIDTH} {'─'*_DIR_WIDTH} {'─'*38}")
|
|
110
|
+
for svc in services:
|
|
111
|
+
# Infer framework label from cmd for display
|
|
112
|
+
fw = "unknown"
|
|
113
|
+
cmd_lower = svc.cmd.lower()
|
|
114
|
+
if "uvicorn" in cmd_lower: fw = "fastapi"
|
|
115
|
+
elif "manage.py" in cmd_lower: fw = "django"
|
|
116
|
+
elif "flask" in cmd_lower: fw = "flask"
|
|
117
|
+
elif "cargo" in cmd_lower: fw = "rust"
|
|
118
|
+
elif "go run" in cmd_lower: fw = "go"
|
|
119
|
+
elif "vite" in cmd_lower or "npm" in cmd_lower or "bun" in cmd_lower or "pnpm" in cmd_lower:
|
|
120
|
+
fw = "node/vite"
|
|
121
|
+
cwd_display = svc.cwd if svc.cwd != "." else "(root)"
|
|
122
|
+
print(
|
|
123
|
+
f" [{svc.name.upper()}] {fw:<{_FW_WIDTH}} "
|
|
124
|
+
f"{cwd_display:<{_DIR_WIDTH}} {svc.cmd}"
|
|
125
|
+
)
|
|
126
|
+
print()
|
|
127
|
+
print(f" A {BOLD}dev.toml{RESET} will be saved so you won't be asked again.")
|
|
128
|
+
print()
|
|
129
|
+
|
|
130
|
+
# ── Confirmation prompt ────────────────────────────────────────────────
|
|
131
|
+
try:
|
|
132
|
+
answer = input(" Start these services? [Y/n] ").strip().lower()
|
|
133
|
+
except (KeyboardInterrupt, EOFError):
|
|
134
|
+
print("\nAborted.")
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
if answer in ("n", "no"):
|
|
138
|
+
print("\nRun 'devlauncher init' to configure services manually.")
|
|
139
|
+
sys.exit(0)
|
|
140
|
+
|
|
141
|
+
# ── Write dev.toml ─────────────────────────────────────────────────────
|
|
142
|
+
toml_content = services_to_toml(services)
|
|
143
|
+
try:
|
|
144
|
+
Path("dev.toml").write_text(toml_content, encoding="utf-8")
|
|
145
|
+
print(f"\n {BOLD}dev.toml{RESET} saved. Edit it anytime to adjust services.\n")
|
|
146
|
+
except OSError as e:
|
|
147
|
+
print(f"{YELLOW}⚠ Could not write dev.toml: {e} — continuing without saving.{RESET}")
|
|
148
|
+
|
|
149
|
+
# Run install phase before resolving ports or starting services
|
|
150
|
+
_run_install_phase(services)
|
|
151
|
+
|
|
152
|
+
services = _resolve_services(services)
|
|
153
|
+
|
|
154
|
+
# Print startup header
|
|
155
|
+
print(f"\n{BOLD}devlauncher{RESET}")
|
|
156
|
+
for i, svc in enumerate(services):
|
|
157
|
+
color = _PALETTE[i % len(_PALETTE)]
|
|
158
|
+
print(f" {color}{BOLD}[{svc.name.upper()}]{RESET} http://localhost:{svc.port}")
|
|
159
|
+
print()
|
|
160
|
+
|
|
161
|
+
run_services(services)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
devlauncher/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""dev.toml config loading and port-reference resolution.
|
|
2
|
+
|
|
3
|
+
Config format:
|
|
4
|
+
|
|
5
|
+
[services.api]
|
|
6
|
+
cmd = "uvicorn main:app --reload"
|
|
7
|
+
port = 8000
|
|
8
|
+
cwd = "api"
|
|
9
|
+
|
|
10
|
+
[services.web]
|
|
11
|
+
cmd = "npm run dev"
|
|
12
|
+
port = 5173
|
|
13
|
+
cwd = "frontend"
|
|
14
|
+
env = { PUBLIC_API_URL = "http://localhost:{api.port}" }
|
|
15
|
+
|
|
16
|
+
Port references like {api.port} are resolved after conflict detection,
|
|
17
|
+
so if the API lands on 8001 instead of 8000, PUBLIC_API_URL gets the
|
|
18
|
+
correct URL automatically.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass, field, replace
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
# tomllib is stdlib in Python 3.11+; fall back to tomli on 3.9-3.10
|
|
28
|
+
if sys.version_info >= (3, 11):
|
|
29
|
+
import tomllib
|
|
30
|
+
else:
|
|
31
|
+
try:
|
|
32
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"Python < 3.11 requires the 'tomli' package: pip install tomli"
|
|
36
|
+
) from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Service:
|
|
41
|
+
name: str
|
|
42
|
+
cmd: str
|
|
43
|
+
port: int
|
|
44
|
+
cwd: str = "."
|
|
45
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
46
|
+
install_cmd: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config(path: str = "dev.toml") -> List[Service]:
|
|
50
|
+
"""Parse dev.toml and return services in declaration order."""
|
|
51
|
+
config_path = Path(path)
|
|
52
|
+
if not config_path.exists():
|
|
53
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
54
|
+
|
|
55
|
+
with config_path.open("rb") as f:
|
|
56
|
+
data = tomllib.load(f)
|
|
57
|
+
|
|
58
|
+
services = []
|
|
59
|
+
for name, cfg in data.get("services", {}).items():
|
|
60
|
+
if "cmd" not in cfg:
|
|
61
|
+
raise ValueError(f"Service '{name}' is missing required field 'cmd'")
|
|
62
|
+
if "port" not in cfg:
|
|
63
|
+
raise ValueError(f"Service '{name}' is missing required field 'port'")
|
|
64
|
+
services.append(Service(
|
|
65
|
+
name=name,
|
|
66
|
+
cmd=cfg["cmd"],
|
|
67
|
+
port=int(cfg["port"]),
|
|
68
|
+
cwd=cfg.get("cwd", "."),
|
|
69
|
+
env={k: str(v) for k, v in cfg.get("env", {}).items()},
|
|
70
|
+
install_cmd=cfg.get("install_cmd") or None,
|
|
71
|
+
))
|
|
72
|
+
|
|
73
|
+
if not services:
|
|
74
|
+
raise ValueError("No services defined in dev.toml")
|
|
75
|
+
|
|
76
|
+
return services
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_REF_PATTERN = re.compile(r"\{(\w+)\.port\}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_port_refs(
|
|
83
|
+
services: List[Service],
|
|
84
|
+
resolved_ports: Dict[str, int],
|
|
85
|
+
) -> List[Service]:
|
|
86
|
+
"""Replace {name.port} placeholders with actual resolved ports.
|
|
87
|
+
|
|
88
|
+
Called after find_free_port() has run for each service, so references
|
|
89
|
+
always reflect the real port in use (not the preferred default).
|
|
90
|
+
"""
|
|
91
|
+
def _replace(m: re.Match) -> str:
|
|
92
|
+
ref = m.group(1)
|
|
93
|
+
if ref in resolved_ports:
|
|
94
|
+
return str(resolved_ports[ref])
|
|
95
|
+
return m.group(0) # leave unresolved refs as-is
|
|
96
|
+
|
|
97
|
+
result = []
|
|
98
|
+
for svc in services:
|
|
99
|
+
# Build a resolver that also handles {self.port} as an alias
|
|
100
|
+
# for the current service's own resolved port.
|
|
101
|
+
self_ports = {**resolved_ports, "self": resolved_ports.get(svc.name, svc.port)}
|
|
102
|
+
|
|
103
|
+
def _replace_with_self(m: re.Match, _sp: dict = self_ports) -> str:
|
|
104
|
+
ref = m.group(1)
|
|
105
|
+
if ref in _sp:
|
|
106
|
+
return str(_sp[ref])
|
|
107
|
+
return m.group(0)
|
|
108
|
+
|
|
109
|
+
resolved_cmd = _REF_PATTERN.sub(_replace_with_self, svc.cmd)
|
|
110
|
+
resolved_env = {
|
|
111
|
+
key: _REF_PATTERN.sub(_replace_with_self, val)
|
|
112
|
+
for key, val in svc.env.items()
|
|
113
|
+
}
|
|
114
|
+
result.append(replace(svc, cmd=resolved_cmd, env=resolved_env))
|
|
115
|
+
return result
|
devlauncher/discovery.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Auto-discovery: infer services from project structure without a dev.toml.
|
|
2
|
+
|
|
3
|
+
Scans the project directory, collects evidence (signals) per subdirectory,
|
|
4
|
+
scores each directory for frontend/backend roles, and returns the best
|
|
5
|
+
match per role as a list of Service objects ready for the runner.
|
|
6
|
+
|
|
7
|
+
Accuracy is intentional: confident detections start silently, uncertain ones
|
|
8
|
+
warn the user, and anything too ambiguous recommends `devlauncher init` instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from .config import Service
|
|
18
|
+
|
|
19
|
+
# ── Signal weights ─────────────────────────────────────────────────────────────
|
|
20
|
+
HIGH = 3
|
|
21
|
+
MEDIUM = 2
|
|
22
|
+
LOW = 1
|
|
23
|
+
|
|
24
|
+
# ── Confidence thresholds ──────────────────────────────────────────────────────
|
|
25
|
+
CONFIDENT = "CONFIDENT" # score >= 6
|
|
26
|
+
LIKELY = "LIKELY" # score 4–5
|
|
27
|
+
PLAUSIBLE = "PLAUSIBLE" # score == 3
|
|
28
|
+
UNCERTAIN = "UNCERTAIN" # score 1–2
|
|
29
|
+
SKIP = "SKIP" # score == 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _score_to_confidence(score: int) -> str:
|
|
33
|
+
if score >= 6: return CONFIDENT
|
|
34
|
+
if score >= 4: return LIKELY
|
|
35
|
+
if score >= 3: return PLAUSIBLE
|
|
36
|
+
if score >= 1: return UNCERTAIN
|
|
37
|
+
return SKIP
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Directories to skip during scan ───────────────────────────────────────────
|
|
41
|
+
_SKIP_DIRS = {
|
|
42
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", "env",
|
|
43
|
+
"dist", "build", ".next", ".svelte-kit", ".nuxt", "target",
|
|
44
|
+
"coverage", ".coverage", "docs", "tests", "test", "scripts",
|
|
45
|
+
".github", ".idea", ".vscode", "tmp", "temp", "logs", ".cache",
|
|
46
|
+
"static", "public", "assets", "migrations",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Internal result types ──────────────────────────────────────────────────────
|
|
51
|
+
@dataclass
|
|
52
|
+
class _DirScore:
|
|
53
|
+
path: Path
|
|
54
|
+
frontend_score: int = 0
|
|
55
|
+
backend_score: int = 0
|
|
56
|
+
frontend_framework: Optional[str] = None
|
|
57
|
+
backend_framework: Optional[str] = None
|
|
58
|
+
package_manager: str = "npm"
|
|
59
|
+
warnings: List[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class DiscoveredService:
|
|
64
|
+
name: str
|
|
65
|
+
role: str # "frontend" | "backend"
|
|
66
|
+
cmd: str
|
|
67
|
+
port: int
|
|
68
|
+
cwd: str
|
|
69
|
+
confidence: str
|
|
70
|
+
framework: str
|
|
71
|
+
warnings: List[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
75
|
+
def _read_text(path: Path) -> str:
|
|
76
|
+
try:
|
|
77
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
78
|
+
except OSError:
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_json(path: Path) -> dict:
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(path.read_text(encoding="utf-8", errors="ignore"))
|
|
85
|
+
except (OSError, json.JSONDecodeError):
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _has_dep(text: str, *names: str) -> bool:
|
|
90
|
+
"""Case-insensitive check for package names in a deps file."""
|
|
91
|
+
lower = text.lower()
|
|
92
|
+
return any(name.lower() in lower for name in names)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _detect_package_manager(directory: Path) -> str:
|
|
96
|
+
if (directory / "bun.lockb").exists():
|
|
97
|
+
return "bun"
|
|
98
|
+
if (directory / "pnpm-lock.yaml").exists():
|
|
99
|
+
return "pnpm"
|
|
100
|
+
if (directory / "yarn.lock").exists():
|
|
101
|
+
return "yarn"
|
|
102
|
+
return "npm"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _npm_dev_cmd(pm: str, port_ref: str) -> str:
|
|
106
|
+
"""Build the dev command for a given package manager."""
|
|
107
|
+
if pm == "bun":
|
|
108
|
+
return f"bun run dev -- --port {port_ref}"
|
|
109
|
+
if pm == "pnpm":
|
|
110
|
+
return f"pnpm run dev -- --port {port_ref}"
|
|
111
|
+
if pm == "yarn":
|
|
112
|
+
return f"yarn dev --port {port_ref}"
|
|
113
|
+
return f"npm run dev -- --port {port_ref}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Directory scorer ───────────────────────────────────────────────────────────
|
|
117
|
+
def _score_directory(directory: Path) -> _DirScore:
|
|
118
|
+
result = _DirScore(path=directory)
|
|
119
|
+
name = directory.name.lower()
|
|
120
|
+
|
|
121
|
+
# ── Package manager ────────────────────────────────────────────────────────
|
|
122
|
+
result.package_manager = _detect_package_manager(directory)
|
|
123
|
+
|
|
124
|
+
# ── package.json analysis ──────────────────────────────────────────────────
|
|
125
|
+
pkg_json = _read_json(directory / "package.json")
|
|
126
|
+
has_pkg = bool(pkg_json)
|
|
127
|
+
scripts = pkg_json.get("scripts", {})
|
|
128
|
+
deps = {
|
|
129
|
+
**pkg_json.get("dependencies", {}),
|
|
130
|
+
**pkg_json.get("devDependencies", {}),
|
|
131
|
+
}
|
|
132
|
+
has_dev_script = "dev" in scripts
|
|
133
|
+
has_start_script = "start" in scripts
|
|
134
|
+
|
|
135
|
+
# Detect JS frontend frameworks from package.json deps
|
|
136
|
+
if has_pkg:
|
|
137
|
+
if "vite" in deps or "vite" in pkg_json.get("devDependencies", {}):
|
|
138
|
+
result.frontend_score += HIGH
|
|
139
|
+
result.frontend_framework = "vite"
|
|
140
|
+
if "next" in deps:
|
|
141
|
+
result.frontend_score += HIGH
|
|
142
|
+
result.frontend_framework = "nextjs"
|
|
143
|
+
if "nuxt" in deps:
|
|
144
|
+
result.frontend_score += HIGH
|
|
145
|
+
result.frontend_framework = "nuxt"
|
|
146
|
+
if "@angular/core" in deps:
|
|
147
|
+
result.frontend_score += HIGH
|
|
148
|
+
result.frontend_framework = "angular"
|
|
149
|
+
if "svelte" in deps or "@sveltejs/kit" in deps:
|
|
150
|
+
result.frontend_score += HIGH
|
|
151
|
+
result.frontend_framework = "svelte"
|
|
152
|
+
|
|
153
|
+
if has_dev_script:
|
|
154
|
+
result.frontend_score += HIGH
|
|
155
|
+
elif has_start_script:
|
|
156
|
+
result.backend_score += MEDIUM # node backend pattern
|
|
157
|
+
elif has_pkg:
|
|
158
|
+
result.frontend_score += MEDIUM
|
|
159
|
+
|
|
160
|
+
# ── Framework config files ─────────────────────────────────────────────────
|
|
161
|
+
for pattern in ("vite.config.ts", "vite.config.js", "vite.config.mjs"):
|
|
162
|
+
if (directory / pattern).exists():
|
|
163
|
+
result.frontend_score += HIGH
|
|
164
|
+
result.frontend_framework = result.frontend_framework or "vite"
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
for pattern in ("next.config.js", "next.config.ts", "next.config.mjs"):
|
|
168
|
+
if (directory / pattern).exists():
|
|
169
|
+
result.frontend_score += HIGH
|
|
170
|
+
result.frontend_framework = result.frontend_framework or "nextjs"
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
for pattern in ("nuxt.config.js", "nuxt.config.ts"):
|
|
174
|
+
if (directory / pattern).exists():
|
|
175
|
+
result.frontend_score += HIGH
|
|
176
|
+
result.frontend_framework = result.frontend_framework or "nuxt"
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
for pattern in ("svelte.config.js", "svelte.config.ts"):
|
|
180
|
+
if (directory / pattern).exists():
|
|
181
|
+
result.frontend_score += HIGH
|
|
182
|
+
result.frontend_framework = result.frontend_framework or "svelte"
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
if (directory / "angular.json").exists():
|
|
186
|
+
result.frontend_score += HIGH
|
|
187
|
+
result.frontend_framework = result.frontend_framework or "angular"
|
|
188
|
+
|
|
189
|
+
# ── Python backend signals ─────────────────────────────────────────────────
|
|
190
|
+
python_deps_text = ""
|
|
191
|
+
for deps_file in ("requirements.txt", "requirements-dev.txt"):
|
|
192
|
+
p = directory / deps_file
|
|
193
|
+
if p.exists():
|
|
194
|
+
python_deps_text += _read_text(p)
|
|
195
|
+
|
|
196
|
+
pyproject = directory / "pyproject.toml"
|
|
197
|
+
if pyproject.exists():
|
|
198
|
+
python_deps_text += _read_text(pyproject)
|
|
199
|
+
|
|
200
|
+
if python_deps_text:
|
|
201
|
+
if _has_dep(python_deps_text, "fastapi", "uvicorn"):
|
|
202
|
+
result.backend_score += HIGH
|
|
203
|
+
result.backend_framework = "fastapi"
|
|
204
|
+
elif _has_dep(python_deps_text, "flask"):
|
|
205
|
+
result.backend_score += HIGH
|
|
206
|
+
result.backend_framework = "flask"
|
|
207
|
+
elif _has_dep(python_deps_text, "django"):
|
|
208
|
+
result.backend_score += HIGH
|
|
209
|
+
result.backend_framework = "django"
|
|
210
|
+
|
|
211
|
+
if (directory / "manage.py").exists():
|
|
212
|
+
result.backend_score += HIGH
|
|
213
|
+
result.backend_framework = result.backend_framework or "django"
|
|
214
|
+
|
|
215
|
+
if (directory / "main.py").exists():
|
|
216
|
+
result.backend_score += LOW
|
|
217
|
+
result.backend_framework = result.backend_framework or "python"
|
|
218
|
+
|
|
219
|
+
if (directory / "app.py").exists():
|
|
220
|
+
result.backend_score += LOW
|
|
221
|
+
result.backend_framework = result.backend_framework or "flask"
|
|
222
|
+
|
|
223
|
+
# ── Rust backend ───────────────────────────────────────────────────────────
|
|
224
|
+
cargo = directory / "Cargo.toml"
|
|
225
|
+
if cargo.exists():
|
|
226
|
+
cargo_text = _read_text(cargo)
|
|
227
|
+
if _has_dep(cargo_text, "axum", "actix-web", "rocket", "warp"):
|
|
228
|
+
result.backend_score += HIGH
|
|
229
|
+
result.backend_framework = "rust"
|
|
230
|
+
else:
|
|
231
|
+
result.backend_score += MEDIUM
|
|
232
|
+
result.backend_framework = result.backend_framework or "rust"
|
|
233
|
+
|
|
234
|
+
# ── Go backend ─────────────────────────────────────────────────────────────
|
|
235
|
+
if (directory / "go.mod").exists():
|
|
236
|
+
result.backend_score += MEDIUM
|
|
237
|
+
result.backend_framework = result.backend_framework or "go"
|
|
238
|
+
if (directory / "main.go").exists():
|
|
239
|
+
result.backend_score += MEDIUM
|
|
240
|
+
result.backend_framework = result.backend_framework or "go"
|
|
241
|
+
|
|
242
|
+
# ── Directory name bonuses ─────────────────────────────────────────────────
|
|
243
|
+
if name in ("frontend", "web", "client", "app", "ui"):
|
|
244
|
+
result.frontend_score += MEDIUM
|
|
245
|
+
if name in ("api", "backend", "server", "service"):
|
|
246
|
+
result.backend_score += MEDIUM
|
|
247
|
+
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── Command + port inference ───────────────────────────────────────────────────
|
|
252
|
+
def _infer_frontend(score: _DirScore) -> Tuple[str, int]:
|
|
253
|
+
"""Return (command, default_port) for a frontend service."""
|
|
254
|
+
pm = score.package_manager
|
|
255
|
+
fw = score.frontend_framework or "vite"
|
|
256
|
+
|
|
257
|
+
if fw == "nextjs":
|
|
258
|
+
cmd = f"{pm} run dev -- -p {{self.port}}" if pm == "npm" else f"{pm} run dev -- --port {{self.port}}"
|
|
259
|
+
return cmd, 3000
|
|
260
|
+
if fw == "nuxt":
|
|
261
|
+
return _npm_dev_cmd(pm, "{self.port}"), 3000
|
|
262
|
+
if fw == "angular":
|
|
263
|
+
return f"ng serve --port {{self.port}}", 4200
|
|
264
|
+
# vite / svelte / generic
|
|
265
|
+
return _npm_dev_cmd(pm, "{self.port}"), 5173
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _infer_backend(score: _DirScore) -> Tuple[str, int, List[str]]:
|
|
269
|
+
"""Return (command, default_port, warnings) for a backend service."""
|
|
270
|
+
fw = score.backend_framework or "python"
|
|
271
|
+
warnings: List[str] = []
|
|
272
|
+
|
|
273
|
+
if fw == "fastapi":
|
|
274
|
+
# Try to find the entry point
|
|
275
|
+
directory = score.path
|
|
276
|
+
entry = "main:app"
|
|
277
|
+
for candidate in ("main.py", "app.py", "server.py", "run.py"):
|
|
278
|
+
if (directory / candidate).exists():
|
|
279
|
+
module = candidate.replace(".py", "")
|
|
280
|
+
entry = f"{module}:app"
|
|
281
|
+
break
|
|
282
|
+
if entry == "main:app" and not (directory / "main.py").exists():
|
|
283
|
+
warnings.append(f"Could not find FastAPI entry point — defaulting to 'main:app'. Update dev.toml if wrong.")
|
|
284
|
+
return f"uvicorn {entry} --reload --port {{self.port}}", 8000, warnings
|
|
285
|
+
|
|
286
|
+
if fw == "flask":
|
|
287
|
+
return "flask run --port {self.port}", 5000, warnings
|
|
288
|
+
|
|
289
|
+
if fw == "django":
|
|
290
|
+
return "python manage.py runserver {self.port}", 8000, warnings
|
|
291
|
+
|
|
292
|
+
if fw == "rust":
|
|
293
|
+
warnings.append("Rust detected — command defaults to 'cargo run'. Port injection via PORT env var may be needed.")
|
|
294
|
+
return "cargo run", 8080, warnings
|
|
295
|
+
|
|
296
|
+
if fw == "go":
|
|
297
|
+
warnings.append("Go detected — command defaults to 'go run .'. You may need to handle port binding in your code.")
|
|
298
|
+
return "go run .", 8080, warnings
|
|
299
|
+
|
|
300
|
+
# Generic node backend
|
|
301
|
+
return "npm run start", 3000, warnings
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ── Install command inference ──────────────────────────────────────────────────
|
|
305
|
+
def _infer_install_cmd(score: _DirScore) -> Optional[str]:
|
|
306
|
+
"""Return the install command for a discovered service, or None if not applicable.
|
|
307
|
+
|
|
308
|
+
Rules:
|
|
309
|
+
- Node project (has package.json): use package-manager-specific install
|
|
310
|
+
- Python project with requirements.txt: "pip install -r requirements.txt"
|
|
311
|
+
- Python project with only pyproject.toml: "pip install -e ."
|
|
312
|
+
- Go project (go.mod present): "go mod download"
|
|
313
|
+
- Rust / anything else: None (cargo fetches deps on build; no pre-install step)
|
|
314
|
+
"""
|
|
315
|
+
directory = score.path
|
|
316
|
+
|
|
317
|
+
# Node — package.json present
|
|
318
|
+
if (directory / "package.json").exists():
|
|
319
|
+
pm = score.package_manager
|
|
320
|
+
if pm == "bun":
|
|
321
|
+
return "bun install"
|
|
322
|
+
if pm == "pnpm":
|
|
323
|
+
return "pnpm install"
|
|
324
|
+
if pm == "yarn":
|
|
325
|
+
return "yarn install"
|
|
326
|
+
return "npm install"
|
|
327
|
+
|
|
328
|
+
# Python — requirements.txt takes priority over requirements-dev.txt
|
|
329
|
+
req_file = None
|
|
330
|
+
if (directory / "requirements.txt").exists():
|
|
331
|
+
req_file = "requirements.txt"
|
|
332
|
+
elif (directory / "requirements-dev.txt").exists():
|
|
333
|
+
req_file = "requirements-dev.txt"
|
|
334
|
+
|
|
335
|
+
if req_file:
|
|
336
|
+
return f"pip install -r {req_file}"
|
|
337
|
+
if (directory / "pyproject.toml").exists():
|
|
338
|
+
return "pip install -e ."
|
|
339
|
+
|
|
340
|
+
# Go
|
|
341
|
+
if (directory / "go.mod").exists():
|
|
342
|
+
return "go mod download"
|
|
343
|
+
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ── Monorepo detection ─────────────────────────────────────────────────────────
|
|
348
|
+
def _is_monorepo(root: Path) -> bool:
|
|
349
|
+
"""Heuristic: looks like a monorepo if packages/ or apps/ exist with 2+ subdirs."""
|
|
350
|
+
for dirname in ("packages", "apps"):
|
|
351
|
+
candidate = root / dirname
|
|
352
|
+
if candidate.is_dir():
|
|
353
|
+
subdirs = [d for d in candidate.iterdir() if d.is_dir()]
|
|
354
|
+
if len(subdirs) >= 2:
|
|
355
|
+
return True
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ── Main discovery function ────────────────────────────────────────────────────
|
|
360
|
+
def discover_services(root: Optional[str] = None) -> Tuple[List[Service], List[str]]:
|
|
361
|
+
"""Scan project and return (services, warnings).
|
|
362
|
+
|
|
363
|
+
Returns up to 2 services (one frontend, one backend).
|
|
364
|
+
Warnings are printed by the caller before starting.
|
|
365
|
+
"""
|
|
366
|
+
root_path = Path(root) if root else Path.cwd()
|
|
367
|
+
warnings: List[str] = []
|
|
368
|
+
|
|
369
|
+
# Monorepo guard
|
|
370
|
+
if _is_monorepo(root_path):
|
|
371
|
+
warnings.append(
|
|
372
|
+
"Monorepo structure detected (packages/ or apps/ with multiple subdirs). "
|
|
373
|
+
"Auto-discovery may be inaccurate. Run 'devlauncher init' to create a dev.toml."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Scan candidate directories (immediate subdirs + root itself)
|
|
377
|
+
candidates: List[_DirScore] = []
|
|
378
|
+
|
|
379
|
+
# Check root
|
|
380
|
+
root_score = _score_directory(root_path)
|
|
381
|
+
root_score.path = root_path
|
|
382
|
+
if root_score.frontend_score > 0 or root_score.backend_score > 0:
|
|
383
|
+
candidates.append(root_score)
|
|
384
|
+
|
|
385
|
+
# Check subdirs
|
|
386
|
+
for entry in sorted(root_path.iterdir()):
|
|
387
|
+
if not entry.is_dir():
|
|
388
|
+
continue
|
|
389
|
+
if entry.name in _SKIP_DIRS or entry.name.startswith("."):
|
|
390
|
+
continue
|
|
391
|
+
score = _score_directory(entry)
|
|
392
|
+
if score.frontend_score > 0 or score.backend_score > 0:
|
|
393
|
+
candidates.append(score)
|
|
394
|
+
|
|
395
|
+
# Pick best frontend and backend
|
|
396
|
+
frontend_candidates = sorted(
|
|
397
|
+
[c for c in candidates if c.frontend_score > 0],
|
|
398
|
+
key=lambda c: c.frontend_score, reverse=True,
|
|
399
|
+
)
|
|
400
|
+
backend_candidates = sorted(
|
|
401
|
+
[c for c in candidates if c.backend_score > 0],
|
|
402
|
+
key=lambda c: c.backend_score, reverse=True,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Warn if multiple strong candidates per role
|
|
406
|
+
if len(frontend_candidates) >= 2:
|
|
407
|
+
top, second = frontend_candidates[0], frontend_candidates[1]
|
|
408
|
+
if second.frontend_score >= HIGH:
|
|
409
|
+
warnings.append(
|
|
410
|
+
f"Multiple frontend candidates found: '{top.path.name}' (score {top.frontend_score}) "
|
|
411
|
+
f"and '{second.path.name}' (score {second.frontend_score}). "
|
|
412
|
+
f"Using '{top.path.name}'. Run 'devlauncher init' if wrong."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if len(backend_candidates) >= 2:
|
|
416
|
+
top, second = backend_candidates[0], backend_candidates[1]
|
|
417
|
+
if second.backend_score >= HIGH:
|
|
418
|
+
warnings.append(
|
|
419
|
+
f"Multiple backend candidates found: '{top.path.name}' (score {top.backend_score}) "
|
|
420
|
+
f"and '{second.path.name}' (score {second.backend_score}). "
|
|
421
|
+
f"Using '{top.path.name}'. Run 'devlauncher init' if wrong."
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
services: List[Service] = []
|
|
425
|
+
|
|
426
|
+
# Build frontend service
|
|
427
|
+
if frontend_candidates:
|
|
428
|
+
fs = frontend_candidates[0]
|
|
429
|
+
confidence = _score_to_confidence(fs.frontend_score)
|
|
430
|
+
if confidence != SKIP:
|
|
431
|
+
cmd, port = _infer_frontend(fs)
|
|
432
|
+
cwd = str(fs.path.relative_to(root_path)) if fs.path != root_path else "."
|
|
433
|
+
svc_warnings = list(fs.warnings)
|
|
434
|
+
if confidence == UNCERTAIN:
|
|
435
|
+
svc_warnings.append(
|
|
436
|
+
f"Low confidence for frontend in '{fs.path.name}' (score {fs.frontend_score}). "
|
|
437
|
+
"Run 'devlauncher init' to verify."
|
|
438
|
+
)
|
|
439
|
+
services.append(Service(
|
|
440
|
+
name="web",
|
|
441
|
+
cmd=cmd,
|
|
442
|
+
port=port,
|
|
443
|
+
cwd=cwd,
|
|
444
|
+
env={},
|
|
445
|
+
install_cmd=_infer_install_cmd(fs),
|
|
446
|
+
))
|
|
447
|
+
warnings.extend(svc_warnings)
|
|
448
|
+
else:
|
|
449
|
+
warnings.append("No frontend service detected.")
|
|
450
|
+
|
|
451
|
+
# Build backend service
|
|
452
|
+
if backend_candidates:
|
|
453
|
+
bs = backend_candidates[0]
|
|
454
|
+
confidence = _score_to_confidence(bs.backend_score)
|
|
455
|
+
if confidence != SKIP:
|
|
456
|
+
cmd, port, extra_warnings = _infer_backend(bs)
|
|
457
|
+
cwd = str(bs.path.relative_to(root_path)) if bs.path != root_path else "."
|
|
458
|
+
svc_warnings = list(bs.warnings) + extra_warnings
|
|
459
|
+
if confidence == UNCERTAIN:
|
|
460
|
+
svc_warnings.append(
|
|
461
|
+
f"Low confidence for backend in '{bs.path.name}' (score {bs.backend_score}). "
|
|
462
|
+
"Run 'devlauncher init' to verify."
|
|
463
|
+
)
|
|
464
|
+
services.append(Service(
|
|
465
|
+
name="api",
|
|
466
|
+
cmd=cmd,
|
|
467
|
+
port=port,
|
|
468
|
+
cwd=cwd,
|
|
469
|
+
env={},
|
|
470
|
+
install_cmd=_infer_install_cmd(bs),
|
|
471
|
+
))
|
|
472
|
+
warnings.extend(svc_warnings)
|
|
473
|
+
else:
|
|
474
|
+
warnings.append("No backend service detected.")
|
|
475
|
+
|
|
476
|
+
# 3+ services total → recommend dev.toml
|
|
477
|
+
total = len(frontend_candidates) + len(backend_candidates)
|
|
478
|
+
if total >= 4:
|
|
479
|
+
warnings.append(
|
|
480
|
+
f"{total} potential services found. "
|
|
481
|
+
"For complex projects, 'devlauncher init' is strongly recommended."
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return services, warnings
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ── TOML serialisation ─────────────────────────────────────────────────────────
|
|
488
|
+
def services_to_toml(services: List[Service]) -> str:
|
|
489
|
+
"""Serialise discovered services to a dev.toml string.
|
|
490
|
+
|
|
491
|
+
Written when the user confirms auto-discovery results, so subsequent
|
|
492
|
+
runs skip discovery entirely and use this file directly.
|
|
493
|
+
"""
|
|
494
|
+
lines: List[str] = [
|
|
495
|
+
"# Generated by devlauncher auto-discovery.",
|
|
496
|
+
"# Edit freely - devlauncher will use this file on all future runs.",
|
|
497
|
+
"",
|
|
498
|
+
]
|
|
499
|
+
for svc in services:
|
|
500
|
+
lines.append(f"[services.{svc.name}]")
|
|
501
|
+
lines.append(f'cmd = "{svc.cmd}"')
|
|
502
|
+
lines.append(f"port = {svc.port}")
|
|
503
|
+
lines.append(f'cwd = "{svc.cwd}"')
|
|
504
|
+
if svc.install_cmd:
|
|
505
|
+
lines.append(f'install_cmd = "{svc.install_cmd}"')
|
|
506
|
+
if svc.env:
|
|
507
|
+
env_parts = ", ".join(
|
|
508
|
+
f'{k} = "{v}"' for k, v in svc.env.items()
|
|
509
|
+
)
|
|
510
|
+
lines.append(f"env = {{ {env_parts} }}")
|
|
511
|
+
lines.append("")
|
|
512
|
+
return "\n".join(lines)
|
devlauncher/installer.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Dependency install detection and execution.
|
|
2
|
+
|
|
3
|
+
Before starting services, devlauncher checks whether dependencies are present
|
|
4
|
+
and runs the appropriate install command if not.
|
|
5
|
+
|
|
6
|
+
Detection heuristics:
|
|
7
|
+
Node.js — node_modules/ missing, or lock file newer than node_modules/
|
|
8
|
+
Python — no .venv/, venv/, or env/ directory present
|
|
9
|
+
Go — go mod download is idempotent; always run
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .config import Service
|
|
20
|
+
|
|
21
|
+
# Import ANSI codes used for [INSTALL:NAME] labels
|
|
22
|
+
from .runner import BOLD, RESET
|
|
23
|
+
|
|
24
|
+
_NODE_INSTALL_PREFIXES = ("npm install", "yarn install", "pnpm install", "bun install")
|
|
25
|
+
_NODE_LOCK_FILES = ("package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb")
|
|
26
|
+
_PYTHON_INSTALL_PREFIXES = ("pip install", "uv pip install", "uv sync", "poetry install")
|
|
27
|
+
_PYTHON_VENV_DIRS = (".venv", "venv", "env")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def needs_install(service: "Service") -> bool:
|
|
31
|
+
"""Return True if the service's dependencies need to be installed.
|
|
32
|
+
|
|
33
|
+
Returns False immediately if service.install_cmd is None or empty.
|
|
34
|
+
"""
|
|
35
|
+
if not service.install_cmd:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
cwd = Path(service.cwd)
|
|
39
|
+
cmd = service.install_cmd
|
|
40
|
+
|
|
41
|
+
# ── Node.js ────────────────────────────────────────────────────────────────
|
|
42
|
+
if any(cmd.startswith(prefix) for prefix in _NODE_INSTALL_PREFIXES):
|
|
43
|
+
node_modules = cwd / "node_modules"
|
|
44
|
+
if not node_modules.exists():
|
|
45
|
+
return True
|
|
46
|
+
# Stale check: any lock file newer than node_modules/ → reinstall
|
|
47
|
+
nm_mtime = node_modules.stat().st_mtime
|
|
48
|
+
for lock_name in _NODE_LOCK_FILES:
|
|
49
|
+
lock_path = cwd / lock_name
|
|
50
|
+
if lock_path.exists() and lock_path.stat().st_mtime > nm_mtime:
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# ── Python ─────────────────────────────────────────────────────────────────
|
|
55
|
+
if any(cmd.startswith(prefix) for prefix in _PYTHON_INSTALL_PREFIXES):
|
|
56
|
+
for venv_dir in _PYTHON_VENV_DIRS:
|
|
57
|
+
if (cwd / venv_dir).is_dir():
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# ── Go: go mod download is fast and idempotent — always run ───────────────
|
|
62
|
+
if cmd.startswith("go mod download"):
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_install(service: "Service", color: str) -> int:
|
|
69
|
+
"""Run service.install_cmd, streaming output with a colored label prefix.
|
|
70
|
+
|
|
71
|
+
Returns the process exit code. Callers should warn on non-zero but
|
|
72
|
+
should NOT abort — services can still start even if install partially
|
|
73
|
+
fails (e.g. optional dev deps).
|
|
74
|
+
"""
|
|
75
|
+
if not service.install_cmd:
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
label = f"INSTALL:{service.name.upper()}"
|
|
79
|
+
is_win = sys.platform == "win32"
|
|
80
|
+
cmd = service.install_cmd
|
|
81
|
+
|
|
82
|
+
print(f"{color}{BOLD}[{label}]{RESET} {cmd}", flush=True)
|
|
83
|
+
|
|
84
|
+
proc = subprocess.Popen(
|
|
85
|
+
cmd if is_win else shlex.split(cmd),
|
|
86
|
+
cwd=service.cwd or None,
|
|
87
|
+
stdout=subprocess.PIPE,
|
|
88
|
+
stderr=subprocess.STDOUT,
|
|
89
|
+
text=True,
|
|
90
|
+
shell=is_win,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
for line in proc.stdout: # type: ignore[union-attr]
|
|
94
|
+
stripped = line.rstrip()
|
|
95
|
+
if stripped:
|
|
96
|
+
print(f"{color}{BOLD}[{label}]{RESET} {stripped}", flush=True)
|
|
97
|
+
|
|
98
|
+
proc.wait()
|
|
99
|
+
return proc.returncode
|
devlauncher/ports.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Port detection utilities.
|
|
2
|
+
|
|
3
|
+
Checks both IPv4 (127.0.0.1) and IPv6 (::1) localhost because Node.js 18+
|
|
4
|
+
binds to ::1 by default. Checking only 127.0.0.1 gives false "port free"
|
|
5
|
+
results when Vite or other Node-based servers are running.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_port_free(port: int) -> bool:
|
|
12
|
+
"""Return True only if port is free on both IPv4 and IPv6 localhost."""
|
|
13
|
+
for family, addr in [
|
|
14
|
+
(socket.AF_INET, "127.0.0.1"),
|
|
15
|
+
(socket.AF_INET6, "::1"),
|
|
16
|
+
]:
|
|
17
|
+
try:
|
|
18
|
+
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
19
|
+
s.bind((addr, port))
|
|
20
|
+
except OSError:
|
|
21
|
+
return False
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_free_port(start: int) -> int:
|
|
26
|
+
"""Return the first free port at or above `start`."""
|
|
27
|
+
port = start
|
|
28
|
+
while not _is_port_free(port):
|
|
29
|
+
port += 1
|
|
30
|
+
return port
|
devlauncher/runner.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Service runner: subprocess lifecycle, log streaming, graceful shutdown.
|
|
2
|
+
|
|
3
|
+
Each service runs as a subprocess with stdout/stderr merged. Two daemon
|
|
4
|
+
threads per service consume the output and print it with a colored prefix
|
|
5
|
+
([API], [WEB], etc.) so all services are visible in one terminal.
|
|
6
|
+
|
|
7
|
+
Shutdown is two-phase:
|
|
8
|
+
1. SIGTERM all processes, wait up to 5 seconds each
|
|
9
|
+
2. SIGKILL any that did not exit in time
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from typing import List
|
|
19
|
+
|
|
20
|
+
from .config import Service
|
|
21
|
+
|
|
22
|
+
# ANSI colors — cycling palette for arbitrary number of services
|
|
23
|
+
RESET = "\033[0m"
|
|
24
|
+
BOLD = "\033[1m"
|
|
25
|
+
YELLOW = "\033[93m"
|
|
26
|
+
|
|
27
|
+
_PALETTE = [
|
|
28
|
+
"\033[94m", # blue
|
|
29
|
+
"\033[92m", # green
|
|
30
|
+
"\033[95m", # magenta
|
|
31
|
+
"\033[96m", # cyan
|
|
32
|
+
"\033[91m", # red
|
|
33
|
+
"\033[93m", # yellow
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _enable_windows_ansi() -> None:
|
|
38
|
+
"""Enable ANSI escape codes in Windows 10+ console."""
|
|
39
|
+
if sys.platform != "win32":
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
import ctypes
|
|
43
|
+
handle = ctypes.windll.kernel32.GetStdHandle(-11)
|
|
44
|
+
mode = ctypes.c_ulong()
|
|
45
|
+
ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
|
46
|
+
ctypes.windll.kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _stream(proc: subprocess.Popen, label: str, color: str) -> None:
|
|
52
|
+
"""Read lines from proc.stdout and print with colored label prefix."""
|
|
53
|
+
try:
|
|
54
|
+
for line in proc.stdout: # type: ignore[union-attr]
|
|
55
|
+
stripped = line.rstrip()
|
|
56
|
+
if stripped:
|
|
57
|
+
print(f"{color}{BOLD}[{label}]{RESET} {stripped}", flush=True)
|
|
58
|
+
except ValueError:
|
|
59
|
+
# Pipe closed (process exited) — normal during shutdown
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_services(services: List[Service]) -> None:
|
|
64
|
+
"""Start all services, stream their logs, and block until they exit.
|
|
65
|
+
|
|
66
|
+
Handles SIGINT (Ctrl+C) and SIGTERM with graceful two-phase shutdown.
|
|
67
|
+
"""
|
|
68
|
+
_enable_windows_ansi()
|
|
69
|
+
|
|
70
|
+
is_win = sys.platform == "win32"
|
|
71
|
+
procs: list[tuple[subprocess.Popen, str, str]] = []
|
|
72
|
+
|
|
73
|
+
for i, svc in enumerate(services):
|
|
74
|
+
color = _PALETTE[i % len(_PALETTE)]
|
|
75
|
+
label = svc.name.upper()
|
|
76
|
+
|
|
77
|
+
env = os.environ.copy()
|
|
78
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
79
|
+
env["FORCE_COLOR"] = "1"
|
|
80
|
+
env.update(svc.env)
|
|
81
|
+
|
|
82
|
+
# shlex.split handles quoted args correctly on POSIX; shell=True on Windows
|
|
83
|
+
cmd = svc.cmd if is_win else shlex.split(svc.cmd)
|
|
84
|
+
|
|
85
|
+
proc = subprocess.Popen(
|
|
86
|
+
cmd,
|
|
87
|
+
cwd=svc.cwd or None,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.STDOUT,
|
|
90
|
+
text=True,
|
|
91
|
+
env=env,
|
|
92
|
+
shell=is_win,
|
|
93
|
+
)
|
|
94
|
+
procs.append((proc, label, color))
|
|
95
|
+
threading.Thread(
|
|
96
|
+
target=_stream,
|
|
97
|
+
args=(proc, label, color),
|
|
98
|
+
daemon=True,
|
|
99
|
+
).start()
|
|
100
|
+
|
|
101
|
+
def _shutdown(sig=None, frame=None) -> None:
|
|
102
|
+
print(f"\n{YELLOW}⏹ Shutting down...{RESET}", flush=True)
|
|
103
|
+
for proc, _, _ in procs:
|
|
104
|
+
proc.terminate()
|
|
105
|
+
for proc, _, _ in procs:
|
|
106
|
+
try:
|
|
107
|
+
proc.wait(timeout=5)
|
|
108
|
+
except subprocess.TimeoutExpired:
|
|
109
|
+
proc.kill()
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
113
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
114
|
+
|
|
115
|
+
# Block until all processes exit naturally
|
|
116
|
+
for proc, _, _ in procs:
|
|
117
|
+
proc.wait()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devlauncher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Start all your dev services with one command
|
|
5
|
+
Author-email: Ayush Jhunjhunwala <ayushjhun13@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/the-non-expert/devlauncher
|
|
8
|
+
Project-URL: Repository, https://github.com/the-non-expert/devlauncher
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/the-non-expert/devlauncher/issues
|
|
10
|
+
Keywords: dev,services,runner,process,developer-tools,cli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# devlauncher
|
|
31
|
+
|
|
32
|
+
Start all your dev services with one command.
|
|
33
|
+
|
|
34
|
+
## The Problem
|
|
35
|
+
|
|
36
|
+
Every multi-service project means opening multiple terminals, remembering startup commands, and
|
|
37
|
+
dealing with silent port conflicts. devlauncher runs everything in one place.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install devlauncher
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.9+.
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
**Zero-config** — run in any project root:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
cd my-project
|
|
53
|
+
devlauncher
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
devlauncher scans your project, detects services (Vite, FastAPI, Django, etc.), shows you what it found,
|
|
57
|
+
and asks once to confirm. On confirmation it writes a `dev.toml` and starts everything. It never
|
|
58
|
+
asks again.
|
|
59
|
+
|
|
60
|
+
**Manual** — create a `dev.toml`:
|
|
61
|
+
|
|
62
|
+
```toml
|
|
63
|
+
[services.api]
|
|
64
|
+
cmd = "uvicorn main:app --reload --port {self.port}"
|
|
65
|
+
port = 8000
|
|
66
|
+
cwd = "api"
|
|
67
|
+
|
|
68
|
+
[services.web]
|
|
69
|
+
cmd = "npm run dev -- --port {self.port}"
|
|
70
|
+
port = 5173
|
|
71
|
+
cwd = "frontend"
|
|
72
|
+
env = { VITE_API_URL = "http://localhost:{api.port}" }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then run:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
devlauncher
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## How It Works
|
|
82
|
+
|
|
83
|
+
- Scans subdirectories for framework signals (package.json, requirements.txt, Cargo.toml, etc.)
|
|
84
|
+
- Infers services by role (frontend/backend), shows a discovery report
|
|
85
|
+
- Prompts once to confirm; writes `dev.toml` so it never asks again
|
|
86
|
+
- Runs dependency install phase (`npm install`, `pip install`, etc.) if needed
|
|
87
|
+
- Checks ports on both IPv4 and IPv6 — finds a free port if your preferred one is taken
|
|
88
|
+
- Starts all services with color-coded, prefixed log output
|
|
89
|
+
- Shuts down cleanly on Ctrl+C (SIGTERM → 5s timeout → SIGKILL)
|
|
90
|
+
|
|
91
|
+
## Configuration (dev.toml)
|
|
92
|
+
|
|
93
|
+
All fields:
|
|
94
|
+
|
|
95
|
+
```toml
|
|
96
|
+
[services.<name>]
|
|
97
|
+
cmd = "command to start this service" # required
|
|
98
|
+
port = 8000 # required; preferred port
|
|
99
|
+
cwd = "subdirectory" # optional; default is project root
|
|
100
|
+
install_cmd = "pip install -r requirements.txt" # optional; runs when deps are missing
|
|
101
|
+
env = { KEY = "value" } # optional; supports port refs
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Port references** — resolved after conflict detection, so they always reflect the actual port in use:
|
|
105
|
+
|
|
106
|
+
- `{self.port}` — this service's resolved port
|
|
107
|
+
- `{api.port}` — another service's resolved port (use the service name as the key)
|
|
108
|
+
|
|
109
|
+
## Features
|
|
110
|
+
|
|
111
|
+
- Zero-config auto-discovery (Vite, Next.js, Nuxt, FastAPI, Django, Flask, Go, Rust)
|
|
112
|
+
- Dual IPv4 + IPv6 port conflict detection (handles Node.js 18+ IPv6-default binding)
|
|
113
|
+
- Dependency install phase with per-service `install_cmd`
|
|
114
|
+
- Port reference interpolation (`{self.port}`, `{api.port}`)
|
|
115
|
+
- Color-coded, prefixed log output per service
|
|
116
|
+
- Graceful shutdown (SIGTERM → 5s timeout → SIGKILL)
|
|
117
|
+
- Cross-platform: macOS, Linux, Windows
|
|
118
|
+
- Zero external dependencies on Python 3.11+ (`tomli` required on 3.9–3.10)
|
|
119
|
+
|
|
120
|
+
## Supported Frameworks (auto-discovery)
|
|
121
|
+
|
|
122
|
+
| Role | Detected |
|
|
123
|
+
|------|----------|
|
|
124
|
+
| Frontend | Vite, Next.js, Nuxt, Angular, SvelteKit |
|
|
125
|
+
| Backend | FastAPI, Django, Flask, Go, Rust (Cargo) |
|
|
126
|
+
| Package managers | npm, yarn, pnpm, bun, pip, go mod |
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
devlauncher/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
devlauncher/cli.py,sha256=h46QLk2qngB7lyuxv8kRl1ldpE1iA7V2-PMiNFetRzM,6299
|
|
3
|
+
devlauncher/config.py,sha256=5vq_JXPo75tDfkArVxvxECo-lp31bleoK4O_Fl88MS0,3499
|
|
4
|
+
devlauncher/discovery.py,sha256=-vKPQLtZmCICPN9QGXoX4hq0qnyhDp_dCESzC-xt0X0,21008
|
|
5
|
+
devlauncher/installer.py,sha256=b0r8emmFf_cXJLQ9cUwYsHLN-NKGuRf6Mg03ovb5o6s,3607
|
|
6
|
+
devlauncher/ports.py,sha256=v_o8kGd3jcMmUUAXmz4mrFBggRLvncT4HWRBFIi7qzs,843
|
|
7
|
+
devlauncher/runner.py,sha256=t3UOaEisJZpmTJgL-O4u015j7oSIOmxzIhTk0tYEmo8,3438
|
|
8
|
+
devlauncher-0.1.0.dist-info/licenses/LICENSE,sha256=dmRhwnGhQW7_A1m6qyQqnBSoupcXfRv78CgwPrLlSEg,1075
|
|
9
|
+
devlauncher-0.1.0.dist-info/METADATA,sha256=8DQ6BFjn7FHHTCRXRSiP3oISXXW1RrqzgvZHfVb5ST8,4245
|
|
10
|
+
devlauncher-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
devlauncher-0.1.0.dist-info/entry_points.txt,sha256=bsLenVO6QJlohgAHzfGsHD0YOYRDYLBSZf8L4elGIko,53
|
|
12
|
+
devlauncher-0.1.0.dist-info/top_level.txt,sha256=ayIdVHnZQXL8uuRdi7vWhrtWBYGQsaEp0Ede3pEmtf8,12
|
|
13
|
+
devlauncher-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayush Jhunjhunwala
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devlauncher
|