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,710 @@
1
+ """``faden dev init`` — bootstrap a full development workspace.
2
+
3
+ Clones all repositories, installs dependencies, starts shared
4
+ infrastructure, runs migrations, and generates a VS Code workspace file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import click
20
+ from click.core import ParameterSource
21
+
22
+ from faden_cli.core.compose import ComposeContext, up as compose_up
23
+ from faden_cli.core.console import console, success, warning, error, info
24
+ from faden_cli.core.detect import detect_docker
25
+ from faden_cli.core.env_gen import dev_env_vars, read_env_file, write_env_file
26
+ from faden_cli.core.git import clone_all_repos
27
+ from faden_cli.core.install import ensure_prerequisites
28
+ from faden_cli.core.registry import (
29
+ BACKEND_DEV_ENV,
30
+ DATABASES,
31
+ INFRA_SERVICES,
32
+ MODULES_COMPAT,
33
+ POSTGRES_CONTAINER,
34
+ backend_dev_env_for,
35
+ )
36
+ from faden_cli.core.rmq import (
37
+ write_definitions as _write_rmq_definitions,
38
+ )
39
+ from faden_cli.core.settings import (
40
+ DevConfig,
41
+ FadenConfig,
42
+ REPO_DIR_MAP,
43
+ load_config,
44
+ save_config,
45
+ )
46
+ from faden_cli.core.workspace import detect_workspace, find_service_dir, resolve_shared_compose
47
+
48
+ from .dev_group import dev_group
49
+
50
+ # Backwards-compatible aliases — the canonical implementations live in
51
+ # faden_cli.core.workspace so dev_up / dev_status resolve paths identically.
52
+ _find_service_dir = find_service_dir
53
+ _resolve_shared_compose = resolve_shared_compose
54
+
55
+
56
+ def _wait_for_postgres(container: str = POSTGRES_CONTAINER, timeout: int = 60) -> bool:
57
+ """Block until the Postgres container reports healthy or running."""
58
+ import time
59
+
60
+ waited = 0
61
+ while waited < timeout:
62
+ try:
63
+ result = subprocess.run(
64
+ [
65
+ "docker", "inspect", "--format",
66
+ "{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}",
67
+ container,
68
+ ],
69
+ capture_output=True,
70
+ text=True,
71
+ )
72
+ status = result.stdout.strip()
73
+ if status in ("healthy", "running"):
74
+ return True
75
+ except Exception:
76
+ pass
77
+ time.sleep(3)
78
+ waited += 3
79
+ return False
80
+
81
+
82
+ def _parse_env_file(path: Path) -> dict[str, str]:
83
+ """Parse a simple KEY=VALUE env file."""
84
+ if not path.exists():
85
+ return {}
86
+
87
+ values: dict[str, str] = {}
88
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
89
+ line = raw_line.strip()
90
+ if not line or line.startswith("#") or "=" not in line:
91
+ continue
92
+ key, value = line.split("=", 1)
93
+ values[key.strip()] = value.strip()
94
+ return values
95
+
96
+
97
+ def _backend_db_creds(backend_dir: Path) -> tuple[str, str, str]:
98
+ """Resolve backend DB user/password/database from backend .env or defaults."""
99
+ defaults = ("faden_backend", "faden_backend", "faden_backend")
100
+ env_values = _parse_env_file(backend_dir / ".env")
101
+ return (
102
+ env_values.get("FADEN_BACKEND_DB_USER", defaults[0]),
103
+ env_values.get("FADEN_BACKEND_DB_PASS", defaults[1]),
104
+ env_values.get("FADEN_BACKEND_DB_BASE", defaults[2]),
105
+ )
106
+
107
+
108
+ def _ensure_backend_role(backend_dir: Path, pg_user: str = "postgres") -> None:
109
+ """Ensure backend DB role exists and can migrate the backend database."""
110
+ db_user, db_pass, db_name = _backend_db_creds(backend_dir)
111
+ role_lit = db_user.replace("'", "''")
112
+ pass_lit = db_pass.replace("'", "''")
113
+ db_lit = db_name.replace("'", "''")
114
+ role_ident = '"' + db_user.replace('"', '""') + '"'
115
+
116
+ create_or_update_role_sql = (
117
+ "DO $$ "
118
+ "BEGIN "
119
+ f" IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{role_lit}') THEN "
120
+ f" CREATE ROLE {role_ident} LOGIN PASSWORD '{pass_lit}'; "
121
+ " ELSE "
122
+ f" ALTER ROLE {role_ident} WITH LOGIN PASSWORD '{pass_lit}'; "
123
+ " END IF; "
124
+ "END "
125
+ "$$;"
126
+ )
127
+ role_result = subprocess.run(
128
+ [
129
+ "docker",
130
+ "exec",
131
+ POSTGRES_CONTAINER,
132
+ "psql",
133
+ "-U",
134
+ pg_user,
135
+ "-v",
136
+ "ON_ERROR_STOP=1",
137
+ "-d",
138
+ "postgres",
139
+ "-c",
140
+ create_or_update_role_sql,
141
+ ],
142
+ capture_output=True,
143
+ text=True,
144
+ )
145
+ if role_result.returncode != 0:
146
+ warning(
147
+ "Could not ensure backend DB role before migrations:\n"
148
+ f"{(role_result.stderr or role_result.stdout).strip()}"
149
+ )
150
+ return
151
+
152
+ grant_db_sql = (
153
+ f"GRANT CONNECT ON DATABASE \"{db_lit}\" TO {role_ident}; "
154
+ f"GRANT ALL PRIVILEGES ON DATABASE \"{db_lit}\" TO {role_ident};"
155
+ )
156
+ subprocess.run(
157
+ [
158
+ "docker",
159
+ "exec",
160
+ POSTGRES_CONTAINER,
161
+ "psql",
162
+ "-U",
163
+ pg_user,
164
+ "-v",
165
+ "ON_ERROR_STOP=1",
166
+ "-d",
167
+ "postgres",
168
+ "-c",
169
+ grant_db_sql,
170
+ ],
171
+ capture_output=True,
172
+ text=True,
173
+ )
174
+ subprocess.run(
175
+ [
176
+ "docker",
177
+ "exec",
178
+ POSTGRES_CONTAINER,
179
+ "psql",
180
+ "-U",
181
+ pg_user,
182
+ "-v",
183
+ "ON_ERROR_STOP=1",
184
+ "-d",
185
+ db_name,
186
+ "-c",
187
+ f"GRANT ALL ON SCHEMA public TO {role_ident};",
188
+ ],
189
+ capture_output=True,
190
+ text=True,
191
+ )
192
+ subprocess.run(
193
+ [
194
+ "docker",
195
+ "exec",
196
+ POSTGRES_CONTAINER,
197
+ "psql",
198
+ "-U",
199
+ pg_user,
200
+ "-v",
201
+ "ON_ERROR_STOP=1",
202
+ "-d",
203
+ db_name,
204
+ "-c",
205
+ (
206
+ f"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {role_ident}; "
207
+ f"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {role_ident}; "
208
+ f"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
209
+ f"GRANT ALL PRIVILEGES ON TABLES TO {role_ident}; "
210
+ f"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
211
+ f"GRANT ALL PRIVILEGES ON SEQUENCES TO {role_ident};"
212
+ ),
213
+ ],
214
+ capture_output=True,
215
+ text=True,
216
+ )
217
+
218
+
219
+ def _ensure_database(db_name: str, pg_user: str = "postgres", *, quiet: bool = False) -> bool:
220
+ """Ensure a database exists inside the shared Postgres container.
221
+
222
+ Returns True when this call created it. ``quiet`` suppresses the
223
+ per-database chatter for callers that run this on every invocation
224
+ (``dev up``) rather than once during setup.
225
+ """
226
+ check_sql = f"SELECT 1 FROM pg_database WHERE datname='{db_name}'"
227
+ result = subprocess.run(
228
+ ["docker", "exec", POSTGRES_CONTAINER, "psql", "-U", pg_user, "-tAc", check_sql],
229
+ capture_output=True,
230
+ text=True,
231
+ )
232
+ if "1" in (result.stdout or ""):
233
+ if not quiet:
234
+ info(f"Database '{db_name}' already exists.")
235
+ return False
236
+
237
+ subprocess.run(
238
+ ["docker", "exec", POSTGRES_CONTAINER, "psql", "-U", pg_user, "-c", f"CREATE DATABASE {db_name};"],
239
+ capture_output=True,
240
+ text=True,
241
+ )
242
+ if not quiet:
243
+ success(f"Created database '{db_name}'.")
244
+ return True
245
+
246
+
247
+ def _install_backend_deps(backend_dir: Path) -> None:
248
+ """Run ``uv sync`` in the backend directory."""
249
+ console.print("[cyan]Installing backend dependencies (uv sync)…[/cyan]")
250
+ result = subprocess.run(["uv", "sync", "--locked"], cwd=str(backend_dir))
251
+ if result.returncode != 0:
252
+ warning("uv sync --locked failed, retrying without --locked…")
253
+ result = subprocess.run(["uv", "sync"], cwd=str(backend_dir))
254
+ if result.returncode != 0:
255
+ error("uv sync failed.")
256
+ return
257
+ success("Backend dependencies installed.")
258
+
259
+
260
+ def _install_frontend_deps(frontend_dir: Path) -> None:
261
+ """Run ``npm install`` in the frontend directory."""
262
+ console.print("[cyan]Installing frontend dependencies (npm install)…[/cyan]")
263
+ result = subprocess.run(["npm", "install"], cwd=str(frontend_dir), shell=(os.name == "nt")) # noqa: S603, S607
264
+ if result.returncode != 0:
265
+ error("npm install failed.")
266
+ return
267
+ success("Frontend dependencies installed.")
268
+
269
+
270
+ def _run_migrations(backend_dir: Path) -> None:
271
+ """Run Alembic migrations for backend and API gateway."""
272
+ console.print("[cyan]Running database migrations…[/cyan]")
273
+ _ensure_backend_role(backend_dir)
274
+ db_user, db_pass, db_base = _backend_db_creds(backend_dir)
275
+ env = os.environ.copy()
276
+ # Ensure backend Alembic connects with the right credentials.
277
+ env.setdefault("FADEN_BACKEND_DB_HOST", "127.0.0.1") # not localhost: see INFRA_LOOPBACK
278
+ env.setdefault("FADEN_BACKEND_DB_PORT", "5432")
279
+ env.setdefault("FADEN_BACKEND_DB_USER", db_user)
280
+ env.setdefault("FADEN_BACKEND_DB_PASS", db_pass)
281
+ env.setdefault("FADEN_BACKEND_DB_BASE", db_base)
282
+ result = subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=str(backend_dir), env=env)
283
+ if result.returncode != 0:
284
+ warning("Backend Alembic migration exited with non-zero code.")
285
+ else:
286
+ success("Backend migrations up to date.")
287
+
288
+ # API gateway migrations (creates llm_gateway_request_log, etc.)
289
+ api_dir = backend_dir.parent / "gateway"
290
+ if api_dir.exists() and (api_dir / "alembic.ini").exists():
291
+ console.print("[cyan]Running API gateway migrations…[/cyan]")
292
+ result = subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=str(api_dir), env=env)
293
+ if result.returncode != 0:
294
+ warning("API gateway Alembic migration exited with non-zero code.")
295
+ else:
296
+ success("API gateway migrations up to date.")
297
+
298
+
299
+ def _generate_vscode_workspace(workspace: Path) -> None:
300
+ """Create a VS Code multi-root workspace file."""
301
+ workspace_file = workspace / "fadenstack.code-workspace"
302
+
303
+ folders = []
304
+ for _gh_name, local_name in sorted(REPO_DIR_MAP.items()):
305
+ repo_path = find_service_dir(workspace, local_name)
306
+ if repo_path.exists():
307
+ rel = os.path.relpath(repo_path, workspace).replace("\\", "/")
308
+ folders.append({"path": rel})
309
+
310
+ content = {
311
+ "folders": folders,
312
+ "settings": {
313
+ "python.defaultInterpreterPath": ".venv/bin/python",
314
+ "editor.formatOnSave": True,
315
+ "[python]": {
316
+ "editor.defaultFormatter": "charliermarsh.ruff",
317
+ },
318
+ "[typescript]": {
319
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
320
+ },
321
+ "[typescriptreact]": {
322
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
323
+ },
324
+ },
325
+ }
326
+ workspace_file.write_text(json.dumps(content, indent=2) + "\n", encoding="utf-8")
327
+ success(f"VS Code workspace file: {workspace_file}")
328
+
329
+
330
+ def _resync_rmq_credentials(shared_dir: Path, *, skip_infra: bool) -> None:
331
+ """Regenerate RMQ ``definitions.json`` from the shared ``.env`` and
332
+ restart the broker so the on-disk users match the in-env passwords.
333
+
334
+ ``shared/rabbitmq/definitions.json`` is generated, not tracked. When it
335
+ is missing or older than the shared ``.env`` (new ``RABBITMQ_*_PASS``
336
+ values), the broker's users drift out of sync and the backend/worker —
337
+ which are told to log in as ``faden-backend`` / ``RABBITMQ_BACKEND_PASS``
338
+ — get ``ACCESS_REFUSED`` from the mgmt API / AMQP layer.
339
+
340
+ Skipped when ``--skip-infra`` (no broker to restart), docker is
341
+ unavailable, or the shared ``.env`` predates the per-service users.
342
+ """
343
+ env_path = shared_dir / ".env"
344
+ env_vars = read_env_file(env_path)
345
+ admin_pass = env_vars.get("RABBITMQ_ADMIN_PASS")
346
+ backend_pass = env_vars.get("RABBITMQ_BACKEND_PASS")
347
+ if not admin_pass or not backend_pass:
348
+ warning("Shared .env has no RABBITMQ_*_PASS — skipping RMQ definitions resync.")
349
+ return
350
+
351
+ profiles = {
352
+ p.strip()
353
+ for p in env_vars.get("MODULES", "").split(",")
354
+ if p.strip()
355
+ }
356
+ defs_path = _write_rmq_definitions(
357
+ shared_dir,
358
+ admin_user=env_vars.get("RABBITMQ_ADMIN_USER", "admin"),
359
+ admin_pass=admin_pass,
360
+ backend_pass=backend_pass,
361
+ api_pass=env_vars.get("RABBITMQ_API_PASS", backend_pass),
362
+ pii_pass=env_vars.get("RABBITMQ_PII_PASS") if "pii" in profiles else None,
363
+ )
364
+ info(f"RMQ definitions regenerated: {defs_path}")
365
+
366
+ if skip_infra:
367
+ info("Skipping RMQ restart (--skip-infra); definitions load on next broker start.")
368
+ return
369
+ docker = shutil.which("docker")
370
+ if not docker or not _wait_for_postgres(timeout=10):
371
+ info("Docker not ready — RMQ definitions will load on next broker start.")
372
+ return
373
+
374
+ container = "faden-rmq" # explicit container_name in the shared compose
375
+ running = subprocess.run(
376
+ [docker, "inspect", "-f", "{{.State.Running}}", container],
377
+ capture_output=True,
378
+ text=True,
379
+ )
380
+ if running.returncode != 0:
381
+ info("RMQ container not found — definitions will load on first broker start.")
382
+ return
383
+
384
+ # Recreate the broker: the container mounts ./rabbitmq/definitions.json,
385
+ # but load_definitions runs once at startup, so a plain restart of an
386
+ # already-running container keeps the old users.
387
+ if subprocess.run([docker, "rm", "-f", container], capture_output=True).returncode != 0:
388
+ warning("Could not remove the RMQ container — restart it manually to load new credentials.")
389
+ return
390
+ compose_file = next(
391
+ (p for p in (shared_dir / "docker-compose.yaml", shared_dir / "docker-compose.yml") if p.exists()),
392
+ None,
393
+ )
394
+ if not compose_file:
395
+ warning("Shared compose file not found for RMQ restart.")
396
+ return
397
+ ctx = ComposeContext(
398
+ compose_files=[str(compose_file)],
399
+ env_file=str(env_path),
400
+ project_dir=str(shared_dir),
401
+ )
402
+ rc = compose_up(ctx, services=["faden-rmq"], detach=True)
403
+ if rc != 0:
404
+ warning("docker compose up faden-rmq failed — retry after infra is up.")
405
+ return
406
+ # Wait until the broker accepts logins with the new service user.
407
+ # rabbitmqctl prints "… ok" (3.x) or "… Success" (4.x); either is
408
+ # accompanied by exit code 0, but we check both to be safe.
409
+ for _ in range(60):
410
+ time.sleep(2)
411
+ rc = subprocess.run(
412
+ [docker, "exec", container, "rabbitmqctl",
413
+ "authenticate_user", "faden-backend", backend_pass],
414
+ capture_output=True,
415
+ text=True,
416
+ )
417
+ out = (rc.stdout or "").lower()
418
+ if rc.returncode == 0 and ("ok" in out or "success" in out):
419
+ success("RMQ credentials resynced (faden-backend accepts login).")
420
+ return
421
+ warning("RMQ broker restarted; could not confirm faden-backend login within 120s.")
422
+
423
+
424
+ def _resolve_init_workspace(workspace: str, *, explicit: bool) -> Path:
425
+ """Decide where ``dev init`` sets up.
426
+
427
+ Adopts a checkout the developer already has rather than cloning a second
428
+ copy beside it — which is what happened whenever ``init`` was run from
429
+ somewhere other than the exact directory holding the code.
430
+
431
+ An *explicit* path is honoured as given: it is an instruction about where
432
+ the workspace should live, and silently relocating it to some ancestor
433
+ that happens to contain a checkout would be the wrong kind of clever.
434
+ Detection therefore applies only to the default argument.
435
+ """
436
+ target = Path(workspace).resolve()
437
+ if explicit:
438
+ return target
439
+ detected = detect_workspace()
440
+ if detected and detected != target:
441
+ info(f"Existing checkout detected at {detected} — using it as the workspace.")
442
+ return detected
443
+ return target
444
+
445
+
446
+ @dev_group.command("init")
447
+ @click.argument("workspace", type=click.Path(), default=".")
448
+ @click.option("--ssh", is_flag=True, help="Clone using SSH instead of HTTPS.")
449
+ @click.option("--branch", "-b", default="main", show_default=True, help="Branch to checkout after cloning.")
450
+ @click.option("--overwrite", is_flag=True, help="Remove and re-clone existing repos.")
451
+ @click.option("--skip-infra", is_flag=True, help="Skip starting shared infrastructure.")
452
+ @click.option("--skip-deps", is_flag=True, help="Skip installing dependencies.")
453
+ @click.option("--skip-migrations", is_flag=True, help="Skip running database migrations.")
454
+ @click.option(
455
+ "--modules",
456
+ default=None,
457
+ help="Comma-separated list of modules to enable (e.g. pii,auth).",
458
+ )
459
+ @click.option("--force-env", is_flag=True, help="Regenerate .env files even if they already exist.")
460
+ @click.option("--install-prereqs", is_flag=True, help="Auto-install missing prerequisites (uv, git, node).")
461
+ def dev_init(
462
+ workspace: str,
463
+ *,
464
+ ssh: bool,
465
+ branch: str,
466
+ overwrite: bool,
467
+ skip_infra: bool,
468
+ skip_deps: bool,
469
+ skip_migrations: bool,
470
+ modules: str | None,
471
+ force_env: bool,
472
+ install_prereqs: bool,
473
+ ) -> None:
474
+ """Bootstrap a full Fadenstack development workspace.
475
+
476
+ Clones all repositories, installs dependencies, starts shared
477
+ infrastructure, runs migrations, and generates a VS Code workspace.
478
+
479
+ \b
480
+ Steps:
481
+ 1. Clone all Fadenstack repositories
482
+ 2. Generate .env with dev credentials
483
+ 3. Start shared infrastructure (Postgres, Redis, RabbitMQ, …)
484
+ 4. Ensure databases exist
485
+ 5. Install backend (uv) and frontend (npm) dependencies
486
+ 6. Run Alembic migrations
487
+ 7. Generate VS Code workspace file
488
+
489
+ Example:
490
+
491
+ faden dev init --workspace ~/projects/fadenstack --branch feature/my-work
492
+ """
493
+ explicit = (
494
+ click.get_current_context().get_parameter_source("workspace")
495
+ is not ParameterSource.DEFAULT
496
+ )
497
+ workspace_path = _resolve_init_workspace(workspace, explicit=explicit)
498
+ workspace_path.mkdir(parents=True, exist_ok=True)
499
+
500
+ # Parse --modules into profile list
501
+ profiles: list[str] = []
502
+ if modules:
503
+ for m in modules.split(","):
504
+ m = m.strip().lower()
505
+ if m in MODULES_COMPAT:
506
+ profiles.append(MODULES_COMPAT[m]["profile"])
507
+ else:
508
+ warning(f"Unknown module '{m}'. Available: {', '.join(MODULES_COMPAT)}")
509
+
510
+ console.print(f"\n[bold magenta]fadenstack Developer Workspace Setup[/bold magenta]")
511
+ console.print(f"[dim]Workspace: {workspace_path}[/dim]")
512
+ if profiles:
513
+ console.print(f"[dim]Modules: {', '.join(profiles)}[/dim]")
514
+ console.print()
515
+
516
+ # ── Prerequisites ─────────────────────────────────────────────
517
+ console.print("[bold cyan]Checking prerequisites…[/bold cyan]")
518
+ all_ok = ensure_prerequisites(install=install_prereqs)
519
+ if not all_ok:
520
+ error(
521
+ "Some prerequisites are missing.\n"
522
+ " Run: faden dev doctor --install"
523
+ )
524
+ sys.exit(1)
525
+
526
+ # Docker daemon must also be running
527
+ docker_info = detect_docker()
528
+ if not docker_info.daemon_running:
529
+ error(f"Docker daemon is not running. {docker_info.daemon_hint}")
530
+ if docker_info.error:
531
+ info(f" Detail: {docker_info.error}")
532
+ sys.exit(1)
533
+
534
+ success("Prerequisites OK.")
535
+
536
+ # ── 1. Clone repos ────────────────────────────────────────────
537
+ # Repos already present are skipped, so re-running against an existing
538
+ # workspace pulls only what is missing rather than touching the tree.
539
+ console.print("\n[bold cyan]Step 1: Cloning missing repositories…[/bold cyan]")
540
+ clone_method = "ssh" if ssh else "https"
541
+
542
+ # Resolve GitHub token for cloning private repos (if configured)
543
+ existing_cfg = load_config()
544
+ github_token = existing_cfg.dev.github_token if existing_cfg.dev else ""
545
+
546
+ results = clone_all_repos(list(REPO_DIR_MAP.keys()), target_dir=workspace_path, method=clone_method, branch=branch, force=overwrite, token=github_token)
547
+
548
+ cloned = sum(1 for r in results if r.cloned)
549
+ skipped = sum(1 for r in results if r.skipped)
550
+ errors = [r for r in results if r.error]
551
+ info(f"Cloned: {cloned}, Skipped (already exist): {skipped}, Errors: {len(errors)}")
552
+ for r in errors:
553
+ error(f" {r.repo}: {r.error}")
554
+
555
+ # ── 2. Generate .env ──────────────────────────────────────────
556
+ console.print("\n[bold cyan]Step 2: Generating .env files…[/bold cyan]")
557
+
558
+ # ── 2a. Shared infrastructure .env ────────────────────────────
559
+ shared_dir = workspace_path / "shared"
560
+ if not shared_dir.is_dir():
561
+ shared_dir = workspace_path / "fadenstack" / "shared"
562
+ env_path = shared_dir / ".env"
563
+ if env_path.exists() and not force_env:
564
+ warning(f".env already exists at {env_path} — skipping (use --force-env to regenerate).")
565
+ from faden_cli.commands.dev.dev_up import heal_dev_env
566
+
567
+ heal_dev_env(env_path)
568
+ else:
569
+ env_vars = dev_env_vars(profiles=profiles)
570
+ write_env_file(env_path, env_vars)
571
+ success(f".env written to {env_path}")
572
+
573
+ # ── 2b. Backend local .env (so uv run uses localhost) ─────────
574
+ backend_dir = _find_service_dir(workspace_path, "backend")
575
+ backend_env_path = backend_dir / ".env"
576
+ # Resolve the broker credentials from the shared env BEFORE writing the
577
+ # backend env, so the backend logs in as the per-service AMQP user that
578
+ # the shared RabbitMQ definitions create (faden-backend /
579
+ # RABBITMQ_BACKEND_PASS), not the hard-coded guest/guest default.
580
+ shared_env_path = env_path if env_path.exists() else (
581
+ workspace_path / "shared" / ".env"
582
+ )
583
+ backend_env = backend_dev_env_for(shared_env_path)
584
+ if backend_env_path.exists() and not force_env:
585
+ warning(f"Backend .env already exists at {backend_env_path} — skipping (use --force-env to regenerate).")
586
+ elif backend_dir.exists():
587
+ write_env_file(backend_env_path, backend_env)
588
+ success(f"Backend .env written to {backend_env_path}")
589
+
590
+ # ── 2c. Resync RabbitMQ credentials with the shared .env ──────
591
+ # definitions.json is not tracked: it holds the users' password
592
+ # hashes, written from the shared .env. Write it (and the snippet
593
+ # that loads it) and recreate the broker so the backend and worker
594
+ # can authenticate as faden-backend / RABBITMQ_BACKEND_PASS.
595
+ _resync_rmq_credentials(shared_dir, skip_infra=skip_infra)
596
+
597
+ # ── 3. Start shared infrastructure ────────────────────────────
598
+ if not skip_infra:
599
+ console.print("\n[bold cyan]Step 3: Starting shared infrastructure…[/bold cyan]")
600
+ compose_file = _resolve_shared_compose(workspace_path)
601
+ if compose_file:
602
+ ctx = ComposeContext(
603
+ compose_files=[str(compose_file)],
604
+ env_file=str(env_path) if env_path.exists() else None,
605
+ project_dir=str(compose_file.parent),
606
+ )
607
+ returncode = compose_up(ctx, services=INFRA_SERVICES, detach=True)
608
+ if returncode != 0:
609
+ error("docker compose up failed for shared infrastructure.")
610
+ else:
611
+ console.print("[cyan]Waiting for Postgres…[/cyan]")
612
+ if _wait_for_postgres():
613
+ success("Shared infrastructure is running.")
614
+ else:
615
+ warning("Postgres did not become ready in time.")
616
+ else:
617
+ warning("Could not find shared compose file. Skipping infrastructure startup.")
618
+ else:
619
+ info("Skipping infrastructure startup (--skip-infra).")
620
+
621
+ # ── 4. Ensure databases ───────────────────────────────────────
622
+ if not skip_infra:
623
+ console.print("\n[bold cyan]Step 4: Ensuring databases…[/bold cyan]")
624
+ for db_name in DATABASES:
625
+ _ensure_database(db_name)
626
+ if backend_dir.exists():
627
+ _ensure_backend_role(backend_dir)
628
+
629
+ # ── 5. Install dependencies ───────────────────────────────────
630
+ if not skip_deps:
631
+ console.print("\n[bold cyan]Step 5: Installing dependencies…[/bold cyan]")
632
+ backend_dir = _find_service_dir(workspace_path, "backend")
633
+ frontend_dir = _find_service_dir(workspace_path, "frontend")
634
+
635
+ if backend_dir.exists():
636
+ _install_backend_deps(backend_dir)
637
+ else:
638
+ warning("Backend directory not found. Skipping.")
639
+
640
+ if frontend_dir.exists():
641
+ _install_frontend_deps(frontend_dir)
642
+ else:
643
+ warning("Frontend directory not found. Skipping.")
644
+ else:
645
+ info("Skipping dependency installation (--skip-deps).")
646
+
647
+ # ── 6. Migrations ─────────────────────────────────────────────
648
+ if not skip_migrations and not skip_infra:
649
+ console.print("\n[bold cyan]Step 6: Running migrations…[/bold cyan]")
650
+ backend_dir = _find_service_dir(workspace_path, "backend")
651
+ if backend_dir.exists():
652
+ _run_migrations(backend_dir)
653
+ else:
654
+ warning("Backend directory not found. Skipping migrations.")
655
+ else:
656
+ info("Skipping migrations.")
657
+
658
+ # ── 7. VS Code workspace ─────────────────────────────────────
659
+ console.print("\n[bold cyan]Step 7: Generating VS Code workspace…[/bold cyan]")
660
+ _generate_vscode_workspace(workspace_path)
661
+
662
+ # ── Save config ───────────────────────────────────────────────
663
+ cfg = load_config()
664
+ cfg.install_dir = str(shared_dir)
665
+ cfg.profiles = sorted(set(cfg.profiles or []) | set(profiles))
666
+ cfg.dev = DevConfig(
667
+ workspace_dir=str(workspace_path),
668
+ clone_method=clone_method,
669
+ branch=branch,
670
+ repos=list(REPO_DIR_MAP.keys()),
671
+ )
672
+ save_config(cfg)
673
+ success("Configuration saved.")
674
+
675
+ # ── Summary ───────────────────────────────────────────────────
676
+ console.print("\n[bold green]✨ Development workspace ready![/bold green]")
677
+
678
+ from rich.panel import Panel # noqa: PLC0415
679
+ from rich.text import Text # noqa: PLC0415
680
+
681
+ cred_lines = Text()
682
+ cred_lines.append(" Admin email: ", style="bold")
683
+ cred_lines.append("admin@localhost", style="cyan")
684
+ cred_lines.append("\n Admin password: ", style="bold")
685
+ cred_lines.append("admin", style="cyan")
686
+ cred_lines.append("\n")
687
+ cred_lines.append(
688
+ "\n The dev admin user is created automatically when the\n"
689
+ " backend starts. These credentials are for local dev only.",
690
+ style="dim",
691
+ )
692
+ console.print()
693
+ console.print(
694
+ Panel(
695
+ cred_lines,
696
+ title="[bold cyan]Dev Credentials[/bold cyan]",
697
+ border_style="cyan",
698
+ padding=(1, 2),
699
+ )
700
+ )
701
+
702
+ console.print(
703
+ "\n[dim]Next steps:\n"
704
+ " cd {workspace}\n"
705
+ " faden dev up # start backend + frontend\n"
706
+ " faden dev status # check service status\n"
707
+ " code fadenstack.code-workspace # open in VS Code[/dim]".format(
708
+ workspace=workspace_path
709
+ )
710
+ )