fadenstack 0.4.2__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.
Files changed (99) hide show
  1. faden_cli/__init__.py +16 -0
  2. faden_cli/__main__.py +9 -0
  3. faden_cli/bundle/shared/.gitignore +25 -0
  4. faden_cli/bundle/shared/LICENSE +187 -0
  5. faden_cli/bundle/shared/NOTICE +15 -0
  6. faden_cli/bundle/shared/OFFICIAL.md +9 -0
  7. faden_cli/bundle/shared/README.md +16 -0
  8. faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
  9. faden_cli/bundle/shared/alloy/config.alloy +40 -0
  10. faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
  11. faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
  12. faden_cli/bundle/shared/docker-compose.yaml +998 -0
  13. faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
  14. faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
  15. faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
  16. faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
  17. faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
  18. faden_cli/bundle/shared/initdb/01-init.sql +32 -0
  19. faden_cli/bundle/shared/loki/local-config.yaml +32 -0
  20. faden_cli/bundle/shared/nginx/locations.conf +245 -0
  21. faden_cli/bundle/shared/nginx/maintenance.html +239 -0
  22. faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
  23. faden_cli/bundle/shared/nginx/nginx.conf +44 -0
  24. faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
  25. faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
  26. faden_cli/bundle/shared/nginx/select-config.sh +37 -0
  27. faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
  28. faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
  29. faden_cli/bundle/shared/tls/README.md +30 -0
  30. faden_cli/cli.py +158 -0
  31. faden_cli/commands/__init__.py +1 -0
  32. faden_cli/commands/admin.py +161 -0
  33. faden_cli/commands/backup.py +159 -0
  34. faden_cli/commands/config.py +146 -0
  35. faden_cli/commands/deploy.py +949 -0
  36. faden_cli/commands/dev/__init__.py +1 -0
  37. faden_cli/commands/dev/dev_doctor.py +46 -0
  38. faden_cli/commands/dev/dev_down.py +241 -0
  39. faden_cli/commands/dev/dev_group.py +24 -0
  40. faden_cli/commands/dev/dev_init.py +710 -0
  41. faden_cli/commands/dev/dev_pull.py +297 -0
  42. faden_cli/commands/dev/dev_status.py +151 -0
  43. faden_cli/commands/dev/dev_up.py +1392 -0
  44. faden_cli/commands/doctor.py +225 -0
  45. faden_cli/commands/down.py +75 -0
  46. faden_cli/commands/logs_cmd.py +41 -0
  47. faden_cli/commands/maintenance.py +74 -0
  48. faden_cli/commands/module.py +113 -0
  49. faden_cli/commands/observe.py +95 -0
  50. faden_cli/commands/ports.py +91 -0
  51. faden_cli/commands/rebuild.py +158 -0
  52. faden_cli/commands/restore.py +139 -0
  53. faden_cli/commands/runtime_images.py +174 -0
  54. faden_cli/commands/status.py +71 -0
  55. faden_cli/commands/tls.py +722 -0
  56. faden_cli/commands/tune.py +37 -0
  57. faden_cli/commands/up.py +64 -0
  58. faden_cli/commands/upgrade.py +470 -0
  59. faden_cli/commands/version.py +78 -0
  60. faden_cli/core/__init__.py +1 -0
  61. faden_cli/core/api_client.py +77 -0
  62. faden_cli/core/backup.py +717 -0
  63. faden_cli/core/bootstrap.py +291 -0
  64. faden_cli/core/bundle.py +110 -0
  65. faden_cli/core/clickhouse.py +61 -0
  66. faden_cli/core/compose.py +484 -0
  67. faden_cli/core/console.py +106 -0
  68. faden_cli/core/detect.py +580 -0
  69. faden_cli/core/dev_modules.py +149 -0
  70. faden_cli/core/docker_env.py +147 -0
  71. faden_cli/core/edge.py +94 -0
  72. faden_cli/core/env_gen.py +598 -0
  73. faden_cli/core/git.py +219 -0
  74. faden_cli/core/install.py +288 -0
  75. faden_cli/core/installation.py +186 -0
  76. faden_cli/core/local_node.py +719 -0
  77. faden_cli/core/maintenance.py +82 -0
  78. faden_cli/core/private_files.py +133 -0
  79. faden_cli/core/registry.py +574 -0
  80. faden_cli/core/release_feed.py +342 -0
  81. faden_cli/core/rmq.py +135 -0
  82. faden_cli/core/runtime_images.py +309 -0
  83. faden_cli/core/schedule.py +278 -0
  84. faden_cli/core/settings.py +159 -0
  85. faden_cli/core/sysinfo.py +303 -0
  86. faden_cli/core/tls.py +790 -0
  87. faden_cli/core/tune.py +154 -0
  88. faden_cli/core/update_check.py +254 -0
  89. faden_cli/core/workspace.py +155 -0
  90. faden_cli/templates/env.j2 +33 -0
  91. faden_cli/tui/__init__.py +1 -0
  92. faden_cli/tui/widgets/__init__.py +1 -0
  93. faden_cli/tui/wizard/__init__.py +1 -0
  94. fadenstack-0.4.2.dist-info/METADATA +304 -0
  95. fadenstack-0.4.2.dist-info/RECORD +99 -0
  96. fadenstack-0.4.2.dist-info/WHEEL +4 -0
  97. fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
  98. fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
  99. fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,580 @@
1
+ """System detection — OS, Docker, GPU, ports, disk, RAM.
2
+
3
+ Every detection function returns a typed result dataclass so callers
4
+ can render them however suits (Rich table, Textual widget, JSON, etc.).
5
+ All detection is synchronous and subprocess-based — no Docker SDK
6
+ dependency, just ``docker`` and ``nvidia-smi`` / ``rocm-smi`` on PATH.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import socket
15
+ import subprocess
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ import psutil
20
+
21
+ # ── Result types ──────────────────────────────────────────────────
22
+
23
+
24
+ @dataclass
25
+ class OSInfo:
26
+ """Operating system details."""
27
+
28
+ system: str # "Linux", "Windows", "Darwin"
29
+ release: str
30
+ version: str
31
+ machine: str # "x86_64", "aarch64"
32
+
33
+ @property
34
+ def display(self) -> str:
35
+ """Human-friendly OS string."""
36
+ return f"{self.system} {self.release} ({self.machine})"
37
+
38
+
39
+ @dataclass
40
+ class DockerInfo:
41
+ """Docker Engine + Compose availability."""
42
+
43
+ installed: bool = False
44
+ version: str = ""
45
+ compose_installed: bool = False
46
+ compose_version: str = ""
47
+ daemon_running: bool = False
48
+ error: str = ""
49
+
50
+ @property
51
+ def ok(self) -> bool:
52
+ """Docker, Compose v2, and daemon are all available."""
53
+ return self.installed and self.compose_installed and self.daemon_running
54
+
55
+ @property
56
+ def daemon_hint(self) -> str:
57
+ """Cross-platform hint for starting the Docker daemon."""
58
+ system = platform.system()
59
+ if system in ("Darwin", "Windows"):
60
+ # Docker Desktop or its open-source alternative, Rancher
61
+ # Desktop. The CLI automatically falls back to the Rancher
62
+ # Desktop endpoint when the active one is not reachable.
63
+ if self.error and "dockerDesktop" in self.error:
64
+ return (
65
+ "Start Docker Desktop, or start Rancher Desktop "
66
+ "(with Docker support enabled)."
67
+ )
68
+ return "Start Docker Desktop (or Rancher Desktop)."
69
+ return "Start the Docker daemon: sudo systemctl start docker"
70
+
71
+ @property
72
+ def install_hint(self) -> str:
73
+ """Platform-specific install URL for Docker."""
74
+ system = platform.system()
75
+ if system == "Windows":
76
+ return (
77
+ "https://docs.docker.com/desktop/setup/install/windows-install/ "
78
+ "or https://docs.rancherdesktop.io/"
79
+ )
80
+ if system == "Darwin":
81
+ return "https://docs.docker.com/desktop/setup/install/mac-install/"
82
+ return "https://docs.docker.com/engine/install/"
83
+
84
+
85
+ @dataclass
86
+ class GpuDevice:
87
+ """Single GPU device."""
88
+
89
+ index: int
90
+ name: str
91
+ vram_mb: int
92
+ driver_version: str
93
+ cuda_version: str = ""
94
+
95
+
96
+ @dataclass
97
+ class GpuInfo:
98
+ """GPU detection result."""
99
+
100
+ vendor: str = "none" # "nvidia", "amd", "none"
101
+ devices: list[GpuDevice] = field(default_factory=list)
102
+ driver_version: str = ""
103
+ cuda_version: str = ""
104
+ rocm_version: str = ""
105
+ error: str = ""
106
+
107
+ @property
108
+ def has_gpu(self) -> bool:
109
+ """At least one GPU detected."""
110
+ return len(self.devices) > 0
111
+
112
+ @property
113
+ def total_vram_mb(self) -> int:
114
+ """Sum of VRAM across all devices."""
115
+ return sum(d.vram_mb for d in self.devices)
116
+
117
+ @property
118
+ def display(self) -> str:
119
+ """Short human-friendly GPU summary."""
120
+ if not self.has_gpu:
121
+ return "None detected"
122
+ count = len(self.devices)
123
+ name = self.devices[0].name
124
+ vram = self.total_vram_mb
125
+ suffix = f" × {count}" if count > 1 else ""
126
+ return f"{name}{suffix} · {vram} MB VRAM"
127
+
128
+
129
+ @dataclass
130
+ class DiskInfo:
131
+ """Disk space for a given path."""
132
+
133
+ path: str
134
+ total_gb: float
135
+ free_gb: float
136
+ used_pct: float
137
+
138
+
139
+ @dataclass
140
+ class RamInfo:
141
+ """System memory."""
142
+
143
+ total_gb: float
144
+ available_gb: float
145
+ used_pct: float
146
+
147
+
148
+ @dataclass
149
+ class PortCheck:
150
+ """Result of checking whether a port is in use."""
151
+
152
+ port: int
153
+ label: str
154
+ in_use: bool
155
+ #: Held by one of Fadenstack's own containers: not a conflict.
156
+ ours: bool = False
157
+
158
+
159
+ @dataclass
160
+ class ToolCheck:
161
+ """Result of checking whether a CLI tool is on PATH."""
162
+
163
+ name: str
164
+ found: bool
165
+ version: str = ""
166
+ path: str = ""
167
+ install_hint: str = ""
168
+
169
+
170
+ # ── Install hints for common tools ────────────────────────────────
171
+
172
+ TOOL_INSTALL_HINTS: dict[str, str] = {
173
+ "docker": "https://docs.docker.com/engine/install/",
174
+ "git": "https://git-scm.com/downloads",
175
+ "uv": "pip install uv",
176
+ "node": "https://nodejs.org/en/download",
177
+ "npm": "https://nodejs.org/en/download",
178
+ "poetry": "pip install poetry",
179
+ }
180
+
181
+
182
+ @dataclass
183
+ class SystemReport:
184
+ """Aggregated detection results."""
185
+
186
+ os: OSInfo
187
+ docker: DockerInfo
188
+ gpu: GpuInfo
189
+ ram: RamInfo
190
+ disk: DiskInfo
191
+ ports: list[PortCheck] = field(default_factory=list)
192
+ tools: list[ToolCheck] = field(default_factory=list)
193
+
194
+ @property
195
+ def all_clear(self) -> bool:
196
+ """No blocking issues found."""
197
+ return self.docker.ok and not any(p.in_use for p in self.ports)
198
+
199
+
200
+ # ── Detection functions ───────────────────────────────────────────
201
+
202
+
203
+ def detect_os() -> OSInfo:
204
+ """Detect the operating system."""
205
+ return OSInfo(
206
+ system=platform.system(),
207
+ release=platform.release(),
208
+ version=platform.version(),
209
+ machine=platform.machine(),
210
+ )
211
+
212
+
213
+ def _run(
214
+ cmd: list[str], *, timeout: int = 10, env: dict[str, str] | None = None,
215
+ ) -> subprocess.CompletedProcess[str]:
216
+ """Run a subprocess, capturing output."""
217
+ return subprocess.run( # noqa: S603
218
+ cmd,
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=timeout,
222
+ env=env,
223
+ )
224
+
225
+
226
+ def detect_docker() -> DockerInfo:
227
+ """Detect Docker Engine and Docker Compose v2.
228
+
229
+ Runs against the endpoint chosen by
230
+ :func:`faden_cli.core.docker_env.ensure_docker_host` (the CLI's default
231
+ context first, a live Rancher Desktop endpoint as a fallback). That
232
+ endpoint is exported to ``os.environ`` at CLI startup, so every
233
+ ``docker`` subprocess here inherits it automatically — no re-plumbing.
234
+ """
235
+ info = DockerInfo()
236
+
237
+ docker_bin = shutil.which("docker")
238
+ if not docker_bin:
239
+ info.error = "docker not found on PATH"
240
+ return info
241
+
242
+ # Client-only check (no daemon needed)
243
+ try:
244
+ result = _run([docker_bin, "version", "--format", "{{.Client.Version}}"])
245
+ if result.returncode == 0 and result.stdout.strip():
246
+ info.installed = True
247
+ info.version = result.stdout.strip()
248
+ else:
249
+ stderr = result.stderr.strip()
250
+ # Permission denied means Docker is installed but user lacks socket access
251
+ if "permission denied" in stderr.lower():
252
+ info.installed = True
253
+ info.daemon_running = False
254
+ info.error = (
255
+ "Permission denied on Docker socket. "
256
+ "Add your user to the docker group: "
257
+ "sudo usermod -aG docker $USER && newgrp docker"
258
+ )
259
+ return info
260
+ info.error = stderr or "docker version failed"
261
+ return info
262
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
263
+ info.error = str(exc)
264
+ return info
265
+
266
+ # Daemon reachability + server version in one call
267
+ try:
268
+ result = _run([docker_bin, "version", "--format", "{{.Server.Version}}"], timeout=30)
269
+ if result.returncode == 0 and result.stdout.strip():
270
+ info.daemon_running = True
271
+ info.version = result.stdout.strip()
272
+ else:
273
+ info.daemon_running = False
274
+ stderr = result.stderr.strip()
275
+ if "permission denied" in stderr.lower():
276
+ info.error = (
277
+ "Permission denied on Docker socket. "
278
+ "Add your user to the docker group: "
279
+ "sudo usermod -aG docker $USER && newgrp docker"
280
+ )
281
+ else:
282
+ info.error = stderr
283
+ except subprocess.TimeoutExpired:
284
+ info.daemon_running = False
285
+ info.error = "docker daemon did not respond within 30 s"
286
+ except (FileNotFoundError, OSError):
287
+ info.daemon_running = False
288
+
289
+ # Docker Compose v2 (docker compose subcommand)
290
+ try:
291
+ result = _run([docker_bin, "compose", "version", "--short"])
292
+ if result.returncode == 0 and result.stdout.strip():
293
+ info.compose_installed = True
294
+ info.compose_version = result.stdout.strip().lstrip("v")
295
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
296
+ pass
297
+
298
+ return info
299
+
300
+
301
+ def detect_gpu() -> GpuInfo:
302
+ """Detect GPU vendor and devices (NVIDIA via nvidia-smi, AMD via rocm-smi)."""
303
+ info = GpuInfo()
304
+
305
+ # Try NVIDIA first
306
+ nvidia_smi = shutil.which("nvidia-smi")
307
+ if nvidia_smi:
308
+ try:
309
+ result = _run([
310
+ nvidia_smi,
311
+ "--query-gpu=index,name,memory.total,driver_version",
312
+ "--format=csv,noheader,nounits",
313
+ ])
314
+ if result.returncode == 0 and result.stdout.strip():
315
+ info.vendor = "nvidia"
316
+ for line in result.stdout.strip().splitlines():
317
+ parts = [p.strip() for p in line.split(",")]
318
+ if len(parts) >= 4: # noqa: PLR2004
319
+ try:
320
+ vram = int(float(parts[2]))
321
+ except (ValueError, TypeError):
322
+ vram = 0
323
+ device = GpuDevice(
324
+ index=int(parts[0]),
325
+ name=parts[1],
326
+ vram_mb=vram,
327
+ driver_version=parts[3],
328
+ )
329
+ info.devices.append(device)
330
+ info.driver_version = parts[3]
331
+
332
+ # Get CUDA version
333
+ cuda_result = _run([nvidia_smi])
334
+ if cuda_result.returncode == 0:
335
+ match = re.search(r"CUDA Version:\s*([\d.]+)", cuda_result.stdout)
336
+ if match:
337
+ info.cuda_version = match.group(1)
338
+ for d in info.devices:
339
+ d.cuda_version = match.group(1)
340
+ return info
341
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as exc:
342
+ info.error = str(exc)
343
+
344
+ # Try AMD ROCm
345
+ rocm_smi = shutil.which("rocm-smi")
346
+ if rocm_smi:
347
+ try:
348
+ result = _run([rocm_smi, "--showproductname", "--showmeminfo", "vram", "--csv"])
349
+ if result.returncode == 0 and result.stdout.strip():
350
+ info.vendor = "amd"
351
+ # ROCm CSV parsing is more complex; simplified version
352
+ for i, line in enumerate(result.stdout.strip().splitlines()[1:]):
353
+ parts = [p.strip() for p in line.split(",")]
354
+ if len(parts) >= 2: # noqa: PLR2004
355
+ info.devices.append(
356
+ GpuDevice(
357
+ index=i,
358
+ name=parts[1] if len(parts) > 1 else f"AMD GPU {i}",
359
+ vram_mb=0, # parsed separately in real impl
360
+ driver_version="",
361
+ ),
362
+ )
363
+ # ROCm version
364
+ rocm_ver = _run(["rocm-smi", "--version"])
365
+ if rocm_ver.returncode == 0:
366
+ info.rocm_version = rocm_ver.stdout.strip()
367
+ return info
368
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
369
+ info.error = str(exc)
370
+
371
+ return info
372
+
373
+
374
+ def detect_ram() -> RamInfo:
375
+ """Detect system memory."""
376
+ mem = psutil.virtual_memory()
377
+ return RamInfo(
378
+ total_gb=round(mem.total / (1024**3), 1),
379
+ available_gb=round(mem.available / (1024**3), 1),
380
+ used_pct=mem.percent,
381
+ )
382
+
383
+
384
+ def detect_disk(path: str = "/") -> DiskInfo:
385
+ """Detect disk space for the given path."""
386
+ if platform.system() == "Windows":
387
+ path = "C:\\"
388
+ usage = psutil.disk_usage(path)
389
+ return DiskInfo(
390
+ path=path,
391
+ total_gb=round(usage.total / (1024**3), 1),
392
+ free_gb=round(usage.free / (1024**3), 1),
393
+ used_pct=usage.percent,
394
+ )
395
+
396
+
397
+ # ── Port map (all ports used by Fadenstack) ─────────────────────────
398
+
399
+ from faden_cli.core.registry import KNOWN_PORTS
400
+
401
+
402
+ def check_port(port: int, label: str = "") -> PortCheck:
403
+ """Check if a TCP port is already in use on localhost."""
404
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
405
+ s.settimeout(0.5)
406
+ in_use = s.connect_ex(("127.0.0.1", port)) == 0
407
+ return PortCheck(port=port, label=label, in_use=in_use)
408
+
409
+
410
+ def check_known_ports() -> list[PortCheck]:
411
+ """Check all known Fadenstack ports."""
412
+ return [check_port(port, label) for port, label in KNOWN_PORTS]
413
+
414
+
415
+ #: What a production install publishes on the host, for when the compose file
416
+ #: cannot be asked (no Docker yet). ``check_install_ports`` reads the real list.
417
+ FALLBACK_INSTALL_PORTS: list[tuple[int, str]] = [
418
+ (80, "nginx: console, API, gateway"),
419
+ (443, "nginx: the same over HTTPS, once a certificate is installed"),
420
+ (3001, "grafana"),
421
+ (3002, "langfuse-web"),
422
+ (3100, "loki"),
423
+ (9099, "prometheus"),
424
+ ]
425
+
426
+
427
+ def _published_ports(compose_file: Path, env_file: Path | None) -> list[tuple[int, str]] | None:
428
+ """The host ports *compose_file* publishes, with the service, or ``None`` if Docker cannot say."""
429
+ import json # noqa: PLC0415
430
+
431
+ docker = shutil.which("docker")
432
+ if not docker:
433
+ return None
434
+ cmd = [docker, "compose", "-f", str(compose_file)]
435
+ if env_file is not None and env_file.is_file():
436
+ cmd += ["--env-file", str(env_file)]
437
+ # The compose file requires its secrets (${NAME:?...}) and stops without them, before
438
+ # deploy has written any. Ports do not depend on them: placeholders let it answer, and
439
+ # the real ones never reach this process's output.
440
+ import os # noqa: PLC0415
441
+
442
+ from faden_cli.core.env_gen import REQUIRED_KEYS # noqa: PLC0415
443
+
444
+ env = {**os.environ, **dict.fromkeys(REQUIRED_KEYS, "placeholder")}
445
+ try:
446
+ out = _run([*cmd, "config", "--format", "json"], timeout=30, env=env)
447
+ spec = json.loads(out.stdout or "{}") if out.returncode == 0 else None
448
+ except (OSError, ValueError, subprocess.SubprocessError):
449
+ return None
450
+ if not spec:
451
+ return None
452
+ ports: dict[int, str] = {}
453
+ for name, service in (spec.get("services") or {}).items():
454
+ for mapping in (service or {}).get("ports") or []:
455
+ try:
456
+ ports.setdefault(int(mapping.get("published")), name)
457
+ except (TypeError, ValueError):
458
+ continue
459
+ return sorted(ports.items())
460
+
461
+
462
+ def _ports_held_by_faden() -> set[int]:
463
+ """Host ports Fadenstack's own containers publish (``faden-*``)."""
464
+ docker = shutil.which("docker")
465
+ if not docker:
466
+ return set()
467
+ try:
468
+ out = _run([docker, "ps", "--format", "{{.Names}}\t{{.Ports}}"], timeout=15)
469
+ except (OSError, subprocess.SubprocessError):
470
+ return set()
471
+ held: set[int] = set()
472
+ for line in (out.stdout or "").splitlines():
473
+ name, _, ports = line.partition("\t")
474
+ if not name.startswith("faden-"):
475
+ continue
476
+ # "0.0.0.0:80->80/tcp, [::]:80->80/tcp, 127.0.0.1:9099->9090/tcp"
477
+ for match in re.finditer(r":(\d+)->", ports):
478
+ held.add(int(match.group(1)))
479
+ return held
480
+
481
+
482
+ def check_install_ports(compose_file: Path | None, env_file: Path | None = None) -> list[PortCheck]:
483
+ """The host ports an install needs, and whether something else holds them.
484
+
485
+ Read from the compose file the install runs, so the list is what
486
+ ``docker compose up`` will bind -- a port held by something else stops
487
+ it ("port is already allocated"). Ports Fadenstack's own containers hold
488
+ are marked as such, not as conflicts.
489
+ """
490
+ published = _published_ports(compose_file, env_file) if compose_file else None
491
+ wanted = published if published is not None else FALLBACK_INSTALL_PORTS
492
+ ours = _ports_held_by_faden()
493
+ checks = []
494
+ for port, label in wanted:
495
+ check = check_port(port, label)
496
+ check.ours = check.in_use and port in ours
497
+ checks.append(check)
498
+ return checks
499
+
500
+
501
+ # Directories probed for freshly installed tools that are not yet on
502
+ # the session PATH (e.g. the uv installer drops into ~/.local/bin).
503
+ EXTRA_TOOL_DIRS: tuple[str, ...] = (
504
+ str(Path.home() / ".local" / "bin"),
505
+ str(Path.home() / ".cargo" / "bin"),
506
+ )
507
+
508
+
509
+ def ensure_tool_path() -> None:
510
+ """Prepend common tool dirs to this process's PATH (in-place).
511
+
512
+ Call once at CLI startup so child subprocesses (``uv sync``, ``npm``…)
513
+ can find tools that the user's shell put in ``~/.local/bin`` /
514
+ ``~/.cargo/bin`` but that are missing from the current PATH
515
+ (headless SSH sessions, cron, GUI-launched terminals).
516
+ """
517
+ import os
518
+
519
+ path_parts = os.environ.get("PATH", "").split(os.pathsep)
520
+ for extra in reversed(EXTRA_TOOL_DIRS):
521
+ if extra not in path_parts:
522
+ path_parts.insert(0, extra)
523
+ os.environ["PATH"] = os.pathsep.join(path_parts)
524
+
525
+
526
+ def find_tool_path(name: str) -> str | None:
527
+ """Locate a CLI tool on PATH or in common post-install locations."""
528
+ path = shutil.which(name)
529
+ if path:
530
+ return path
531
+ for extra in EXTRA_TOOL_DIRS:
532
+ candidate = Path(extra) / name
533
+ if candidate.is_file() and candidate.stat().st_mode & 0o111:
534
+ return str(candidate)
535
+ return None
536
+
537
+
538
+ def check_tool(name: str, *, version_flag: str = "--version") -> ToolCheck:
539
+ """Check if a CLI tool exists on PATH (or a known install dir) + version."""
540
+ hint = TOOL_INSTALL_HINTS.get(name, "")
541
+ path = find_tool_path(name)
542
+ if not path:
543
+ return ToolCheck(name=name, found=False, install_hint=hint)
544
+ try:
545
+ result = _run([path, version_flag])
546
+ version = result.stdout.strip() or result.stderr.strip()
547
+ # Extract just the version number from common formats
548
+ match = re.search(r"(\d+\.\d+[\.\d]*)", version)
549
+ ver = match.group(1) if match else version[:60]
550
+ return ToolCheck(name=name, found=True, version=ver, path=path, install_hint=hint)
551
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
552
+ return ToolCheck(name=name, found=True, path=path, install_hint=hint)
553
+
554
+
555
+ def check_dev_tools() -> list[ToolCheck]:
556
+ """Check all tools needed for dev mode."""
557
+ return [
558
+ check_tool("docker"),
559
+ check_tool("git"),
560
+ check_tool("uv"),
561
+ check_tool("node"),
562
+ check_tool("npm"),
563
+ check_tool("poetry"),
564
+ ]
565
+
566
+
567
+ # ── Full system report ────────────────────────────────────────────
568
+
569
+
570
+ def full_report(*, check_ports: bool = True, install_path: str = "/") -> SystemReport:
571
+ """Run all detection checks and return a unified report."""
572
+ return SystemReport(
573
+ os=detect_os(),
574
+ docker=detect_docker(),
575
+ gpu=detect_gpu(),
576
+ ram=detect_ram(),
577
+ disk=detect_disk(install_path),
578
+ ports=check_known_ports() if check_ports else [],
579
+ tools=check_dev_tools(),
580
+ )