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
faden_cli/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """CLI entry point — Click root group and subcommand registration.
2
+
3
+ This module defines the ``faden`` command group and wires up all
4
+ subcommands. Run ``faden --help`` for the full command tree.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ import click
12
+
13
+ from faden_cli import __version__
14
+
15
+
16
+ class AliasedGroup(click.Group):
17
+ """Click group that supports command abbreviations.
18
+
19
+ For instance ``faden st`` resolves to ``faden status`` if no
20
+ other command starts with ``st``.
21
+ """
22
+
23
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
24
+ rv = click.Group.get_command(self, ctx, cmd_name)
25
+ if rv is not None:
26
+ return rv
27
+ matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
28
+ if not matches:
29
+ return None
30
+ if len(matches) == 1:
31
+ return click.Group.get_command(self, ctx, matches[0])
32
+ ctx.fail(f"Ambiguous command '{cmd_name}'. Did you mean: {', '.join(sorted(matches))}?")
33
+ return None # unreachable, but satisfies type checker
34
+
35
+
36
+ @click.group(cls=AliasedGroup, invoke_without_command=True)
37
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.")
38
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress informational output.")
39
+ @click.version_option(version=__version__, prog_name="faden")
40
+ @click.pass_context
41
+ def cli(ctx: click.Context, *, verbose: bool, quiet: bool) -> None:
42
+ """faden — CLI installer and management tool for fadenstack."""
43
+ from faden_cli.core.detect import ensure_tool_path
44
+
45
+ # Make tools installed to ~/.local/bin / ~/.cargo/bin visible to
46
+ # every child subprocess, even when the current shell PATH lacks them
47
+ # (typical for fresh SSH sessions and GUI-launched terminals).
48
+ ensure_tool_path()
49
+
50
+ from faden_cli.core.docker_env import ensure_docker_host
51
+
52
+ # If the configured Docker endpoint is dead but Rancher Desktop's
53
+ # answers, export DOCKER_HOST so every docker subprocess (detect,
54
+ # compose, logs, …) reaches the live daemon. No-op otherwise.
55
+ ensure_docker_host(verbose=verbose, quiet=quiet)
56
+
57
+ ctx.ensure_object(dict)
58
+ ctx.obj["verbose"] = verbose
59
+ ctx.obj["quiet"] = quiet
60
+
61
+ from faden_cli.core import update_check
62
+
63
+ # A newer release: checked while the command runs, said at its end.
64
+ update_check.start(ctx, quiet=quiet)
65
+
66
+ if ctx.invoked_subcommand is None:
67
+ click.echo(ctx.get_help())
68
+
69
+
70
+ # ── Register subcommands ──────────────────────────────────────────
71
+
72
+ def register_core_commands(group: click.Group | None = None) -> None:
73
+ """Import and register all core subcommand groups and commands.
74
+
75
+ Accepts an optional *group* parameter so the EE CLI can call this
76
+ with its own root group. Defaults to ``cli`` when called internally.
77
+ """
78
+ target = group or cli
79
+
80
+ from faden_cli.commands.version import version_cmd
81
+ from faden_cli.commands.doctor import doctor_cmd
82
+ from faden_cli.commands.status import status_cmd
83
+ from faden_cli.commands.up import up_cmd
84
+ from faden_cli.commands.down import down_cmd
85
+ from faden_cli.commands.logs_cmd import logs_cmd
86
+ from faden_cli.commands.config import config_group
87
+ from faden_cli.commands.module import module_group
88
+ from faden_cli.commands.tune import tune_cmd
89
+ from faden_cli.commands.deploy import deploy_cmd
90
+ from faden_cli.commands.rebuild import rebuild_cmd
91
+ from faden_cli.commands.backup import backup_cmd
92
+ from faden_cli.commands.restore import restore_cmd
93
+ from faden_cli.commands.upgrade import upgrade_cmd
94
+ from faden_cli.commands.dev.dev_group import dev_group
95
+ from faden_cli.commands.admin import admin_group
96
+ from faden_cli.commands.observe import observe_group
97
+ from faden_cli.commands.runtime_images import runtime_images_cmd
98
+ from faden_cli.commands.tls import tls_group
99
+ from faden_cli.commands.ports import ports_cmd
100
+ from faden_cli.commands.maintenance import maintenance_group
101
+
102
+ target.add_command(version_cmd, "version")
103
+ target.add_command(doctor_cmd, "doctor")
104
+ target.add_command(status_cmd, "status")
105
+ target.add_command(up_cmd, "up")
106
+ target.add_command(down_cmd, "down")
107
+ target.add_command(logs_cmd, "logs")
108
+ target.add_command(config_group, "config")
109
+ target.add_command(module_group, "module")
110
+ target.add_command(tune_cmd, "tune")
111
+ target.add_command(deploy_cmd, "deploy")
112
+ target.add_command(rebuild_cmd, "rebuild")
113
+ target.add_command(backup_cmd, "backup")
114
+ target.add_command(restore_cmd, "restore")
115
+ target.add_command(upgrade_cmd, "upgrade")
116
+ target.add_command(dev_group, "dev")
117
+ target.add_command(admin_group, "admin")
118
+ target.add_command(observe_group, "observe")
119
+ target.add_command(runtime_images_cmd, "runtime-images")
120
+ target.add_command(tls_group, "tls")
121
+ target.add_command(ports_cmd, "ports")
122
+ target.add_command(maintenance_group, "maintenance")
123
+
124
+
125
+ register_core_commands()
126
+
127
+
128
+ def _use_utf8_output() -> None:
129
+ """Make the console accept the output this CLI actually produces.
130
+
131
+ Windows consoles default to cp1252, and this CLI writes arrows, box rules
132
+ and check marks -- some 17,000 non-ASCII characters across its sources.
133
+ Any one of them reaching a cp1252 stream raises UnicodeEncodeError, so
134
+ ``faden dev up --help`` ended in a traceback instead of printing help,
135
+ and redirecting any command to a file did the same.
136
+
137
+ ``errors="replace"`` rather than a hard switch: where a terminal genuinely
138
+ cannot represent a glyph, a degraded character is a far better outcome
139
+ than a stack trace standing in for the output.
140
+ """
141
+ for stream in (sys.stdout, sys.stderr):
142
+ reconfigure = getattr(stream, "reconfigure", None)
143
+ if reconfigure is None: # pytest capture, or a plain wrapper
144
+ continue
145
+ try:
146
+ reconfigure(encoding="utf-8", errors="replace")
147
+ except (OSError, ValueError): # pragma: no cover - already detached
148
+ pass
149
+
150
+
151
+ def main() -> None:
152
+ """Package entry point."""
153
+ _use_utf8_output()
154
+ cli()
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
@@ -0,0 +1 @@
1
+ """Command implementations for the faden CLI."""
@@ -0,0 +1,161 @@
1
+ """``faden admin`` — administrative commands for a running deployment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import secrets
7
+ import shutil
8
+ import string
9
+ import subprocess
10
+ import sys
11
+
12
+ import click
13
+
14
+ from faden_cli.core.console import console, error, info, success, warning
15
+
16
+
17
+ def _generate_password(length: int = 24) -> str:
18
+ """Generate a random password (letters + digits + punctuation subset)."""
19
+ alphabet = string.ascii_letters + string.digits + "!@#$%&*-_=+"
20
+ return "".join(secrets.choice(alphabet) for _ in range(length))
21
+
22
+
23
+ # Minimal email validation — rejects obvious injection attempts.
24
+ _EMAIL_RE = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-.]+$")
25
+
26
+
27
+ @click.group("admin")
28
+ def admin_group() -> None:
29
+ """Administrative commands for a running Fadenstack deployment."""
30
+
31
+
32
+ @admin_group.command("reset-password")
33
+ @click.option(
34
+ "--email",
35
+ default="admin@localhost",
36
+ show_default=True,
37
+ help="Email address of the user whose password will be reset.",
38
+ )
39
+ @click.option(
40
+ "--password",
41
+ default=None,
42
+ help="New password. Auto-generated if omitted.",
43
+ )
44
+ @click.option(
45
+ "-y", "--yes",
46
+ is_flag=True,
47
+ help="Skip confirmation prompt.",
48
+ )
49
+ def reset_password_cmd(*, email: str, password: str | None, yes: bool) -> None:
50
+ """Reset a user's password directly via the database.
51
+
52
+ This bypasses the API and sets the password hash in PostgreSQL.
53
+ The backend container must be running (needed for password hashing).
54
+ """
55
+ # Validate email early to prevent any injection
56
+ if not _EMAIL_RE.match(email):
57
+ error(f"Invalid email address: {email}")
58
+ sys.exit(1)
59
+
60
+ # The backend's rule for a new password (faden_backend/services/password_policy.py).
61
+ from faden_cli.core.bootstrap import MIN_PASSWORD_LENGTH # noqa: PLC0415
62
+
63
+ if password is not None and len(password) < MIN_PASSWORD_LENGTH:
64
+ error(f"The password needs at least {MIN_PASSWORD_LENGTH} characters (or leave out --password).")
65
+ sys.exit(1)
66
+
67
+ docker = shutil.which("docker")
68
+ if not docker:
69
+ error("docker not found on PATH")
70
+ sys.exit(1)
71
+
72
+ new_password = password or _generate_password()
73
+
74
+ if not yes:
75
+ click.confirm(
76
+ f"Reset password for '{email}'?",
77
+ abort=True,
78
+ )
79
+
80
+ # ── 1. Hash the password inside the backend container ─────
81
+ info("Hashing new password…")
82
+ hash_script = (
83
+ "from fastapi_users.password import PasswordHelper; "
84
+ "print(PasswordHelper().hash(input()))"
85
+ )
86
+
87
+ hash_result = subprocess.run( # noqa: S603
88
+ [
89
+ docker, "exec", "-i", "faden-backend",
90
+ "python", "-c", hash_script,
91
+ ],
92
+ input=new_password,
93
+ capture_output=True,
94
+ text=True,
95
+ check=False,
96
+ )
97
+ if hash_result.returncode != 0:
98
+ error("Failed to hash password — is the backend container running?")
99
+ if hash_result.stderr.strip():
100
+ error(f" {hash_result.stderr.strip()}")
101
+ sys.exit(1)
102
+
103
+ hashed = hash_result.stdout.strip()
104
+ if not hashed:
105
+ error("Password hashing returned empty result.")
106
+ sys.exit(1)
107
+
108
+ # ── 2. Update the password in PostgreSQL ──────────────────
109
+ info("Updating password in database…")
110
+
111
+ # Both `hashed` (bcrypt output we generated) and `email` (validated
112
+ # above by regex) are safe for SQL string interpolation here.
113
+ sql = (
114
+ f"UPDATE \"user\" SET hashed_password = '{hashed}' "
115
+ f"WHERE email = '{email}';"
116
+ )
117
+
118
+ pg_result = subprocess.run( # noqa: S603
119
+ [
120
+ docker, "exec", "faden-postgres",
121
+ "psql", "-U", "postgres", "-d", "faden_backend",
122
+ "-c", sql,
123
+ ],
124
+ capture_output=True,
125
+ text=True,
126
+ check=False,
127
+ )
128
+
129
+ if pg_result.returncode != 0:
130
+ error("Database update failed — is the postgres container running?")
131
+ if pg_result.stderr.strip():
132
+ error(f" {pg_result.stderr.strip()}")
133
+ sys.exit(1)
134
+
135
+ # Check if a row was actually updated
136
+ if "UPDATE 0" in pg_result.stdout:
137
+ warning(f"No user found with email '{email}'.")
138
+ sys.exit(1)
139
+
140
+ success(f"Password reset for '{email}'.")
141
+
142
+ # ── 3. Display credentials ────────────────────────────────
143
+ from rich.panel import Panel
144
+ from rich.text import Text
145
+
146
+ lines = Text()
147
+ lines.append(" Email: ", style="bold")
148
+ lines.append(email, style="cyan")
149
+ lines.append("\n Password: ", style="bold")
150
+ lines.append(new_password, style="cyan")
151
+ lines.append("\n\n Store this in a password manager.", style="dim")
152
+
153
+ console.print()
154
+ console.print(
155
+ Panel(
156
+ lines,
157
+ title="[bold yellow]New Credentials[/bold yellow]",
158
+ border_style="yellow",
159
+ padding=(1, 2),
160
+ )
161
+ )
@@ -0,0 +1,159 @@
1
+ """``faden backup`` — create a backup of all databases and config.
2
+
3
+ Examples:
4
+ faden backup # backup to ./backups
5
+ faden backup --output-dir /mnt/bak # custom output directory
6
+ faden backup --include-volumes # also snapshot Docker volumes
7
+ faden backup --db-only # databases only, skip .env
8
+ faden backup --retain 3 # keep only 3 most recent
9
+ faden backup schedule # every night at 03:00, keep 7
10
+ faden backup schedule --off # stop the nightly backup
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+ from faden_cli.core.backup import create_backup
21
+ from faden_cli.core.compose import build_context_from_config
22
+ from faden_cli.core.console import console, error, info, success, warning
23
+ from faden_cli.core.private_files import private_dir
24
+ from faden_cli.core.settings import load_config
25
+
26
+
27
+ @click.group("backup", invoke_without_command=True)
28
+ @click.option(
29
+ "--output-dir",
30
+ type=click.Path(file_okay=False),
31
+ default="backups",
32
+ show_default=True,
33
+ help="Directory to store backups in.",
34
+ )
35
+ @click.option(
36
+ "--include-volumes",
37
+ is_flag=True,
38
+ default=False,
39
+ help="Also snapshot the install's Docker volumes: knowledge-base uploads, MinIO, logs, traces, Grafana"
40
+ " (not Postgres's data, whose dumps are the backup, nor the models).",
41
+ )
42
+ @click.option(
43
+ "--retain",
44
+ type=int,
45
+ default=5,
46
+ show_default=True,
47
+ help="Keep only the N most recent backups.",
48
+ )
49
+ @click.option(
50
+ "--db-only",
51
+ is_flag=True,
52
+ default=False,
53
+ help="Dump databases only (skip .env and volumes).",
54
+ )
55
+ @click.option("-y", "--yes", is_flag=True, default=False, help="Skip confirmation prompt.")
56
+ @click.pass_context
57
+ def backup_cmd(
58
+ click_ctx: click.Context,
59
+ *,
60
+ output_dir: str,
61
+ include_volumes: bool,
62
+ retain: int,
63
+ db_only: bool,
64
+ yes: bool,
65
+ ) -> None:
66
+ """Create a backup of Fadenstack databases, config, and optionally volumes."""
67
+ if click_ctx.invoked_subcommand is not None:
68
+ return
69
+ console.print("\n[bold magenta]fadenstack — Backup[/bold magenta]\n")
70
+
71
+ cfg = load_config()
72
+ ctx = build_context_from_config(cfg)
73
+
74
+ output_path = Path(output_dir)
75
+ if not output_path.is_absolute():
76
+ output_path = cfg.install_path / output_path
77
+
78
+ if not yes:
79
+ click.confirm(
80
+ f"Create backup in {output_path}?",
81
+ default=True,
82
+ abort=True,
83
+ )
84
+
85
+ result = create_backup(
86
+ ctx,
87
+ output_dir=output_path,
88
+ include_volumes=include_volumes,
89
+ db_only=db_only,
90
+ retain=retain,
91
+ )
92
+
93
+ if not result.ok:
94
+ for msg in result.errors:
95
+ error(msg)
96
+ sys.exit(1)
97
+
98
+ console.print()
99
+ success(f"Backup created: {result.backup_dir}")
100
+ console.print(f" Databases: {', '.join(result.databases)}")
101
+ if result.env_snapshot:
102
+ console.print(f" .env snapshot: {result.env_snapshot}")
103
+ if result.volumes:
104
+ console.print(f" Volumes: {', '.join(result.volumes)}")
105
+ if result.tls_material:
106
+ console.print(
107
+ f" Certificate material: {len(result.tls_material)} file(s), private keys among them;"
108
+ " keep the backup as private as .env"
109
+ )
110
+ if result.manifest_path:
111
+ console.print(f" Manifest: {result.manifest_path.name}")
112
+
113
+
114
+ @backup_cmd.command("schedule")
115
+ @click.option("--at", "at", default="03:00", show_default=True, help="Time of day, HH:MM (server time).")
116
+ @click.option("--retain", type=int, default=7, show_default=True, help="Keep the N most recent backups.")
117
+ @click.option("--off", is_flag=True, default=False, help="Stop the nightly backup.")
118
+ def schedule_cmd(*, at: str, retain: int, off: bool) -> None:
119
+ """Back up every night, keeping the most recent few.
120
+
121
+ The backups land in <install_dir>/backups/, on the same disk as the
122
+ data: copy them elsewhere too (or pass --output-dir to a mounted disk
123
+ with `faden backup` from your own scheduler).
124
+ """
125
+ from faden_cli.core import schedule # noqa: PLC0415
126
+
127
+ if not schedule.available():
128
+ error("No systemd user manager or cron here: schedule `faden backup -y` with your own scheduler.")
129
+ sys.exit(1)
130
+ if off:
131
+ if schedule.remove():
132
+ success("Nightly backup stopped.")
133
+ else:
134
+ info("No nightly backup was scheduled.")
135
+ return
136
+ try:
137
+ hour, minute = (int(part) for part in at.split(":", 1))
138
+ if not (0 <= hour < 24 and 0 <= minute < 60): # noqa: PLR2004
139
+ raise ValueError
140
+ except ValueError:
141
+ error(f"--at wants HH:MM, got {at!r}.")
142
+ sys.exit(2)
143
+ if retain < 1:
144
+ error("--retain must keep at least one backup.")
145
+ sys.exit(2)
146
+ cfg = load_config()
147
+ if not cfg.install_dir:
148
+ error("No install to back up: run `faden deploy` first.")
149
+ sys.exit(1)
150
+ backups = private_dir(cfg.install_path / "backups")
151
+ try:
152
+ how, warnings = schedule.install(hour=hour, minute=minute, retain=retain, log=backups / "backup.log")
153
+ except (RuntimeError, OSError) as exc:
154
+ error(f"Could not schedule the backup: {exc}")
155
+ sys.exit(1)
156
+ success(f"Backing up every night at {hour:02d}:{minute:02d}, keeping {retain}: {backups} ({how})")
157
+ for message in warnings:
158
+ warning(message)
159
+ warning("The backups are on the same disk as the data; copy them elsewhere too.")
@@ -0,0 +1,146 @@
1
+ """``faden config`` — view and manage configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import yaml
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+
12
+ from faden_cli.core.console import console, success, error
13
+ from faden_cli.core.settings import load_config, save_config, config_path
14
+
15
+
16
+ @click.group("config")
17
+ def config_group() -> None:
18
+ """Manage faden configuration."""
19
+
20
+
21
+ @config_group.command("show")
22
+ def config_show() -> None:
23
+ """Display the current configuration."""
24
+ cfg_path = config_path()
25
+
26
+ if not cfg_path.exists():
27
+ console.print(f"[dim]No config file found at {cfg_path}[/dim]")
28
+ console.print("[dim]Using default configuration.[/dim]")
29
+ cfg = load_config()
30
+ import dataclasses
31
+
32
+ raw = yaml.dump(dataclasses.asdict(cfg), default_flow_style=False, sort_keys=False)
33
+ console.print(Panel(Syntax(raw, "yaml", theme="monokai"), title="defaults"))
34
+ return
35
+
36
+ raw = cfg_path.read_text(encoding="utf-8")
37
+ console.print(
38
+ Panel(
39
+ Syntax(raw, "yaml", theme="monokai"),
40
+ title=str(cfg_path),
41
+ border_style="cyan",
42
+ )
43
+ )
44
+
45
+
46
+ @config_group.command("set")
47
+ @click.argument("key")
48
+ @click.argument("value")
49
+ def config_set(key: str, value: str) -> None:
50
+ """Set a configuration value (dot-notation supported).
51
+
52
+ Examples:
53
+
54
+ faden config set install_dir /opt/faden
55
+
56
+ faden config set dev.branch main
57
+ """
58
+ cfg = load_config()
59
+ import dataclasses
60
+
61
+ cfg_dict = dataclasses.asdict(cfg)
62
+
63
+ # Support dot-notation for nested keys (e.g., dev.branch)
64
+ parts = key.split(".")
65
+ target = cfg_dict
66
+ for part in parts[:-1]:
67
+ if part not in target or not isinstance(target[part], dict):
68
+ error(f"Unknown config section: {part}")
69
+ return
70
+ target = target[part]
71
+
72
+ final_key = parts[-1]
73
+ if final_key not in target:
74
+ error(f"Unknown config key: {key}")
75
+ return
76
+
77
+ # Coerce value types
78
+ old_val = target[final_key]
79
+ if isinstance(old_val, bool):
80
+ value_typed: str | bool | int | float | list[str] = value.lower() in ("true", "1", "yes")
81
+ elif isinstance(old_val, int):
82
+ value_typed = int(value)
83
+ elif isinstance(old_val, float):
84
+ value_typed = float(value)
85
+ elif isinstance(old_val, list):
86
+ value_typed = [v.strip() for v in value.split(",")]
87
+ else:
88
+ value_typed = value
89
+
90
+ target[final_key] = value_typed
91
+
92
+ # Rebuild config
93
+ from faden_cli.core.settings import FadenConfig, DevConfig
94
+
95
+ dev_data = cfg_dict.pop("dev", {}) or {}
96
+ dev_cfg = DevConfig(**{k: v for k, v in dev_data.items() if k in DevConfig.__dataclass_fields__})
97
+ new_cfg = FadenConfig(**cfg_dict, dev=dev_cfg)
98
+
99
+ save_config(new_cfg)
100
+ success(f"Set {key} = {value_typed}")
101
+
102
+
103
+ @config_group.command("path")
104
+ def config_path_cmd() -> None:
105
+ """Print the config file path."""
106
+ click.echo(str(config_path()))
107
+
108
+
109
+ @config_group.command("init")
110
+ @click.option("--force", is_flag=True, help="Overwrite existing config file.")
111
+ def config_init(*, force: bool) -> None:
112
+ """Create a default configuration file."""
113
+ cfg_file = config_path()
114
+
115
+ if cfg_file.exists() and not force:
116
+ error(f"Config file already exists at {cfg_file}. Use --force to overwrite.")
117
+ return
118
+
119
+ cfg = load_config()
120
+ save_config(cfg)
121
+ success(f"Configuration written to {cfg_file}")
122
+
123
+
124
+ @config_group.command("edit")
125
+ def config_edit() -> None:
126
+ """Open the config file in $EDITOR."""
127
+ import os
128
+ import subprocess
129
+
130
+ cfg_file = config_path()
131
+ if not cfg_file.exists():
132
+ # Create default first
133
+ cfg = load_config()
134
+ save_config(cfg)
135
+
136
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
137
+ if not editor:
138
+ # Fallback based on platform
139
+ import platform
140
+
141
+ if platform.system() == "Windows":
142
+ editor = "notepad"
143
+ else:
144
+ editor = "nano"
145
+
146
+ subprocess.run([editor, str(cfg_file)], check=False)