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.
@@ -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
@@ -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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devlauncher = devlauncher.cli:main
@@ -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