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,297 @@
1
+ """``faden dev pull`` — pull the latest changes from a branch.
2
+
3
+ Fetches and fast-forwards every repository checked out in the dev
4
+ workspace to the latest commit of the target branch (default:
5
+ ``main``). This is the counterpart of ``dev init --overwrite`` for an already
6
+ bootstrapped workspace: no cloning and no dependency installation — it
7
+ just brings the checkouts up to date.
8
+
9
+ Repositories discovered in the workspace (same convention as
10
+ ``dev status``):
11
+
12
+ * every Fadenstack service in the monorepo layout
13
+ ``<workspace>/fadenstack/<name>``, or
14
+ * flat ``<workspace>/<name>`` checkouts, when that layout is in use;
15
+ * the workspace root itself, if it is a git checkout (running the CLI
16
+ from inside a checkout).
17
+
18
+ Subdirectories sharing a git repository collapse to the single repo
19
+ root, and only repositories that live *inside* the workspace are ever
20
+ touched — unrelated checkouts (e.g. an ``nvm`` install under a
21
+ home-directory workspace) and ancestor checkouts the workspace merely
22
+ sits in are both excluded.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import shutil
28
+ import subprocess
29
+ from pathlib import Path
30
+
31
+ import click
32
+ from rich.table import Table
33
+
34
+ from faden_cli.core.console import console, error, info, success, warning
35
+ from faden_cli.core.registry import REPO_DIR_MAP
36
+ from faden_cli.core.settings import load_config
37
+ from faden_cli.core.workspace import find_service_dir
38
+
39
+ from .dev_group import dev_group
40
+
41
+
42
+ def _git(repo_dir: Path, *args: str, timeout: int = 300) -> subprocess.CompletedProcess | None:
43
+ """Run a git command in *repo_dir*; returns None if git is missing or timed out."""
44
+ git = shutil.which("git")
45
+ if not git:
46
+ return None
47
+ try:
48
+ return subprocess.run( # noqa: S603
49
+ [git, *args],
50
+ cwd=str(repo_dir),
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=timeout,
54
+ )
55
+ except (subprocess.TimeoutExpired, OSError):
56
+ return None
57
+
58
+
59
+ def _git_toplevel(path: Path, within: Path | None = None) -> Path | None:
60
+ """Return the git repo root containing *path*, or None if it is not in one.
61
+
62
+ When *within* is given, the repo root must lie inside *within*
63
+ (inclusive) — ``rev-parse --show-toplevel`` walks up ancestor
64
+ directories, so a candidate inside a checkout merely *containing*
65
+ the workspace would otherwise resolve to that outer repo.
66
+ """
67
+ if not path.is_dir():
68
+ return None
69
+ proc = _git(path, "rev-parse", "--show-toplevel", timeout=15)
70
+ if proc is None or proc.returncode != 0:
71
+ return None
72
+ toplevel = Path(proc.stdout.strip())
73
+ if within is not None:
74
+ try:
75
+ toplevel.resolve().relative_to(within.resolve())
76
+ except ValueError:
77
+ return None
78
+ return toplevel
79
+
80
+
81
+ def _find_repo_dirs(workspace: Path) -> list[tuple[str, Path]]:
82
+ """Return ``(display_name, repo_root)`` for each repo in the workspace.
83
+
84
+ Discovery follows the ``dev status`` convention — registry-driven via
85
+ :func:`find_service_dir` (monorepo layout ``<ws>/fadenstack/<name>``
86
+ or flat ``<ws>/<name>``) — plus the workspace root itself when it is a
87
+ checkout (running the CLI from inside a repo). Candidates that live
88
+ inside the same git repository (e.g. monorepo subdirs) collapse to the
89
+ single repo root, so each checkout is pulled exactly once. Unrelated
90
+ repos that happen to sit in the workspace (e.g. ``.nvm``) are never
91
+ touched.
92
+ """
93
+ found: list[tuple[str, Path]] = []
94
+ seen: set[str] = set()
95
+
96
+ def _add(candidate: Path) -> None:
97
+ toplevel = _git_toplevel(candidate, within=workspace)
98
+ if toplevel is None:
99
+ return
100
+ key = str(toplevel.resolve())
101
+ if key in seen:
102
+ return
103
+ seen.add(key)
104
+ found.append((toplevel.name or str(toplevel), toplevel))
105
+
106
+ if workspace.is_dir():
107
+ _add(workspace)
108
+ for local_name in REPO_DIR_MAP.values():
109
+ _add(find_service_dir(workspace, local_name))
110
+
111
+ return found
112
+
113
+
114
+ def _pull_repo(
115
+ repo_dir: Path, branch: str, force: bool = False, hard: bool = False
116
+ ) -> tuple[str, str]:
117
+ """Pull *branch* into *repo_dir*.
118
+
119
+ Returns a ``(status, detail)`` tuple where status is one of
120
+ ``up-to-date``, ``updated``, ``forced``, ``failed``, ``skipped`` and
121
+ detail is a human-readable explanation (current branch, error
122
+ message, …).
123
+
124
+ With *hard*, the checkout is forcibly aligned to
125
+ ``origin/<branch>`` via three steps, in order:
126
+
127
+ 1. ``git clean -fd`` — remove untracked files; build artifacts that
128
+ are git-ignored (``node_modules/``, ``.venv/``, ``.env``) are
129
+ preserved, everything else that is not tracked is deleted.
130
+ 2. ``git checkout -f <branch>`` — switch to the target branch,
131
+ discarding any changes that would be overwritten by the switch.
132
+ 3. ``git reset --hard origin/<branch>`` — discard the remaining
133
+ tracked modifications and move the working tree exactly to the
134
+ remote branch tip.
135
+
136
+ **No backup is taken** — local edits are irrecoverable.
137
+ """
138
+ proc = _git(repo_dir, "fetch", "origin", "--prune")
139
+ if proc is None:
140
+ return "failed", "git not found on PATH (or fetch timed out)"
141
+ if proc.returncode != 0:
142
+ return "failed", (proc.stderr or proc.stdout).strip() or f"git fetch exited with code {proc.returncode}"
143
+
144
+ # Does the branch even exist on the remote?
145
+ check = _git(repo_dir, "rev-parse", "--verify", "--quiet", f"origin/{branch}")
146
+ if check is not None and check.returncode != 0:
147
+ return "skipped", f"branch '{branch}' not found on remote"
148
+
149
+ if hard:
150
+ cl = _git(repo_dir, "clean", "-fd", timeout=600)
151
+ if cl is None or cl.returncode != 0:
152
+ detail = (cl.stderr if cl else "git timed out").strip() or "git clean -fd failed"
153
+ return "failed", detail
154
+ co = _git(repo_dir, "checkout", "-f", branch)
155
+ if co is None or co.returncode != 0:
156
+ detail = (co.stderr if co else "git timed out").strip() or "git checkout -f failed"
157
+ return "failed", detail
158
+ rs = _git(repo_dir, "reset", "--hard", f"origin/{branch}")
159
+ if rs is None or rs.returncode != 0:
160
+ detail = (rs.stderr if rs else "git reset timed out").strip() or "git reset --hard failed"
161
+ return "failed", detail
162
+ return "forced", f"reset to origin/{branch} (untracked files cleaned, local changes discarded)"
163
+
164
+ cur_proc = _git(repo_dir, "rev-parse", "--abbrev-ref", "HEAD")
165
+ current = cur_proc.stdout.strip() if cur_proc and cur_proc.returncode == 0 else "unknown"
166
+
167
+ if current != branch:
168
+ if force:
169
+ co = _git(repo_dir, "checkout", branch)
170
+ if co is None or co.returncode != 0:
171
+ detail = (co.stderr if co else "git timed out").strip() or "git checkout failed"
172
+ return "failed", detail
173
+ else:
174
+ return "skipped", f"checked out on '{current}' (use --force to switch to '{branch}')"
175
+
176
+ pull = _git(repo_dir, "pull", "--ff-only", "origin", branch)
177
+ if pull is None:
178
+ return "failed", "git pull timed out"
179
+ if pull.returncode != 0:
180
+ detail = (pull.stderr or pull.stdout).strip()
181
+ if "not possible" in detail or "diverged" in detail.lower() or "conflict" in detail.lower():
182
+ return "failed", "local changes conflict with origin — commit or stash them, then re-run"
183
+ return "failed", detail or f"git pull exited with code {pull.returncode}"
184
+
185
+ if "Already up to date" in pull.stdout:
186
+ return "up-to-date", f"on '{branch}'"
187
+ return "updated", f"on '{branch}'"
188
+
189
+
190
+ @dev_group.command("pull")
191
+ @click.option(
192
+ "--branch",
193
+ "-b",
194
+ default="main",
195
+ show_default=True,
196
+ help="Branch to pull from the remote.",
197
+ )
198
+ @click.option(
199
+ "--force",
200
+ "-f",
201
+ is_flag=True,
202
+ help="Switch repos to the target branch even if another branch is checked out.",
203
+ )
204
+ @click.option(
205
+ "--hard",
206
+ "-H",
207
+ is_flag=True,
208
+ help=(
209
+ "DESTRUCTIVE: clean untracked files, discard all local changes and "
210
+ "reset every checkout to the remote branch. Un-mergeable pull "
211
+ "errors (e.g. local changes or untracked files colliding with "
212
+ "incoming files) are resolved this way. No backup is taken."
213
+ ),
214
+ )
215
+ def dev_pull(*, branch: str, force: bool, hard: bool) -> None:
216
+ """Pull the latest changes from the default branch.
217
+
218
+ Fetches and fast-forwards every repository checked out in the dev
219
+ workspace to the latest commit of the target branch (default
220
+ ``main``). Working trees that cannot be fast-forwarded are
221
+ reported and left untouched; ``--hard`` cleans untracked files,
222
+ discards all local changes and resets every checkout to the remote
223
+ branch (destructive).
224
+
225
+ \b
226
+ Examples:
227
+ faden dev pull
228
+ faden dev pull --branch feature-x
229
+ faden dev pull -b main --force
230
+ faden dev pull --hard # destructive
231
+ """
232
+ cfg = load_config()
233
+ workspace = (
234
+ Path(cfg.dev.workspace_dir).expanduser()
235
+ if cfg.dev and cfg.dev.workspace_dir
236
+ else Path.cwd()
237
+ )
238
+
239
+ console.print(f"\n[bold cyan]Pulling [bold]{branch}[/bold] in {workspace}[/bold cyan]\n")
240
+ if not workspace.is_dir():
241
+ error(f"Dev workspace does not exist: {workspace}")
242
+ raise SystemExit(1)
243
+
244
+ repos = _find_repo_dirs(workspace)
245
+ if not repos:
246
+ error(
247
+ f"No git repositories found in {workspace}. "
248
+ "Run [bold]faden dev init[/bold] to bootstrap the workspace first."
249
+ )
250
+ raise SystemExit(1)
251
+
252
+ table = Table(title="Pull results", show_header=True, header_style="bold cyan")
253
+ table.add_column("Repository", style="bold")
254
+ table.add_column("Result")
255
+ table.add_column("Detail")
256
+
257
+ style_by_status = {
258
+ "up-to-date": "[green]up to date[/green]",
259
+ "updated": "[green]updated[/green]",
260
+ "forced": "[green]forced[/green]",
261
+ "failed": "[red]failed[/red]",
262
+ "skipped": "[yellow]skipped[/yellow]",
263
+ }
264
+ counts: dict[str, int] = {}
265
+
266
+ for name, repo_dir in repos:
267
+ status, detail = _pull_repo(repo_dir, branch, force, hard)
268
+ counts[status] = counts.get(status, 0) + 1
269
+ table.add_row(name, style_by_status.get(status, status), detail or "—")
270
+
271
+ console.print(table)
272
+
273
+ failed = counts.get("failed", 0)
274
+ if counts.get("updated", 0):
275
+ success(f"{counts['updated']} repository(ies) updated to [bold]{branch}[/bold].")
276
+ if counts.get("forced", 0):
277
+ success(
278
+ f"{counts['forced']} repository(ies) reset to [bold]{branch}[/bold]"
279
+ " (local changes discarded)."
280
+ )
281
+ if counts.get("up-to-date", 0):
282
+ info(f"{counts['up-to-date']} repository(ies) already up to date.")
283
+ if counts.get("skipped", 0):
284
+ warning(
285
+ f"{counts['skipped']} repository(ies) skipped "
286
+ "(branch missing on remote or checked out on another branch)."
287
+ )
288
+ if failed:
289
+ error(f"{failed} repository(ies) failed — resolve the reported issues and re-run.")
290
+ if not hard:
291
+ warning(
292
+ "If you want to discard ALL local changes and untracked files "
293
+ "and take the remote state as-is, re-run with [bold]--hard[/bold]."
294
+ )
295
+ raise SystemExit(1)
296
+
297
+ info("Next: [bold]faden dev up[/bold] to run the services against the new code.")
@@ -0,0 +1,151 @@
1
+ """``faden dev status`` — show developer workspace status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.table import Table
10
+
11
+ from faden_cli.core.compose import ComposeContext, ps as compose_ps
12
+ from faden_cli.core.console import console, info
13
+ from faden_cli.core.git import current_branch
14
+ from faden_cli.core.registry import DEV_PROCESSES
15
+ from faden_cli.core.settings import REPO_DIR_MAP, load_config
16
+ from faden_cli.core.workspace import find_service_dir, resolve_shared_compose
17
+
18
+ from .dev_group import dev_group
19
+
20
+
21
+ def _check_process(pattern: str) -> bool:
22
+ """Check if a process matching the pattern is running."""
23
+ import platform
24
+
25
+ try:
26
+ if platform.system() == "Windows":
27
+ result = subprocess.run(
28
+ ["powershell", "-Command",
29
+ f"Get-Process | Where-Object {{ $_.CommandLine -like '*{pattern}*' }} | "
30
+ f"Select-Object -First 1 | ForEach-Object {{ $_.Id }}"],
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ return bool(result.stdout.strip())
35
+ else:
36
+ result = subprocess.run(
37
+ ["pgrep", "-f", pattern],
38
+ capture_output=True,
39
+ )
40
+ return result.returncode == 0
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ @dev_group.command("status")
46
+ def dev_status() -> None:
47
+ """Show the status of the developer workspace.
48
+
49
+ Displays:
50
+ • Repository branches and git status
51
+ • Shared infrastructure containers
52
+ • Running dev processes (backend, worker, frontend)
53
+ """
54
+ from faden_cli.core.workspace import resolve_workspace
55
+
56
+ cfg = load_config()
57
+ workspace = resolve_workspace()
58
+
59
+ console.print("[bold magenta]fadenstack Developer Status[/bold magenta]\n")
60
+
61
+ # ── Repositories ──────────────────────────────────────────────
62
+ repo_table = Table(title="Repositories", show_header=True, header_style="bold cyan")
63
+ repo_table.add_column("Repository", style="bold")
64
+ repo_table.add_column("Branch")
65
+ repo_table.add_column("Status")
66
+
67
+ for _gh_name, local_name in sorted(REPO_DIR_MAP.items()):
68
+ repo_path = find_service_dir(workspace, local_name)
69
+ if repo_path.exists():
70
+ branch = current_branch(repo_path) or "—"
71
+ # Quick dirty check
72
+ try:
73
+ result = subprocess.run(
74
+ ["git", "status", "--porcelain"],
75
+ cwd=str(repo_path),
76
+ capture_output=True,
77
+ text=True,
78
+ )
79
+ if result.stdout.strip():
80
+ status = f"[yellow]{len(result.stdout.strip().splitlines())} changed[/yellow]"
81
+ else:
82
+ status = "[green]clean[/green]"
83
+ except Exception:
84
+ status = "[dim]unknown[/dim]"
85
+
86
+ repo_table.add_row(local_name, branch, status)
87
+ else:
88
+ repo_table.add_row(local_name, "—", "[dim]not cloned[/dim]")
89
+
90
+ console.print(repo_table)
91
+
92
+ # ── Shared infrastructure ─────────────────────────────────────
93
+ console.print()
94
+ compose_file = resolve_shared_compose(workspace)
95
+ if compose_file:
96
+ env_file = compose_file.parent / ".env"
97
+ if env_file.exists():
98
+ from faden_cli.commands.dev.dev_up import heal_dev_env
99
+
100
+ heal_dev_env(env_file)
101
+ ctx = ComposeContext(
102
+ compose_files=[str(compose_file)],
103
+ env_file=str(env_file) if env_file.exists() else None,
104
+ project_dir=str(compose_file.parent),
105
+ )
106
+ services = compose_ps(ctx)
107
+
108
+ if services:
109
+ svc_table = Table(title="Shared Infrastructure", show_header=True, header_style="bold cyan")
110
+ svc_table.add_column("Container", style="bold")
111
+ svc_table.add_column("State")
112
+ svc_table.add_column("Health")
113
+ svc_table.add_column("Ports")
114
+
115
+ state_styles = {
116
+ "running": "[green]running[/green]",
117
+ "exited": "[red]exited[/red]",
118
+ "restarting": "[yellow]restarting[/yellow]",
119
+ }
120
+ health_styles = {
121
+ "healthy": "[green]healthy[/green]",
122
+ "unhealthy": "[red]unhealthy[/red]",
123
+ "starting": "[yellow]starting[/yellow]",
124
+ }
125
+
126
+ for svc in sorted(services, key=lambda s: s.service):
127
+ svc_table.add_row(
128
+ svc.service,
129
+ state_styles.get(svc.state, svc.state or "—"),
130
+ health_styles.get(svc.health, svc.health or "—"),
131
+ svc.ports or "—",
132
+ )
133
+ console.print(svc_table)
134
+ else:
135
+ info("No shared infrastructure containers running.")
136
+ else:
137
+ info("Shared compose file not found.")
138
+
139
+ # ── Dev processes ─────────────────────────────────────────────
140
+ console.print()
141
+ proc_table = Table(title="Dev Processes", show_header=True, header_style="bold cyan")
142
+ proc_table.add_column("Process", style="bold")
143
+ proc_table.add_column("Status")
144
+ proc_table.add_column("URL")
145
+
146
+ for proc in DEV_PROCESSES:
147
+ running = _check_process(proc.pattern)
148
+ status = "[green]running[/green]" if running else "[dim]stopped[/dim]"
149
+ proc_table.add_row(proc.name, status, proc.url)
150
+
151
+ console.print(proc_table)