envctl 2.3.1__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 (127) hide show
  1. envctl/__init__.py +10 -0
  2. envctl/__main__.py +6 -0
  3. envctl/adapters/__init__.py +0 -0
  4. envctl/adapters/dotenv.py +76 -0
  5. envctl/adapters/editor.py +39 -0
  6. envctl/adapters/git.py +76 -0
  7. envctl/cli/__init__.py +1 -0
  8. envctl/cli/app.py +92 -0
  9. envctl/cli/callbacks.py +33 -0
  10. envctl/cli/commands/__init__.py +1 -0
  11. envctl/cli/commands/add/__init__.py +5 -0
  12. envctl/cli/commands/add/command.py +237 -0
  13. envctl/cli/commands/check/__init__.py +5 -0
  14. envctl/cli/commands/check/command.py +49 -0
  15. envctl/cli/commands/config/__init__.py +5 -0
  16. envctl/cli/commands/config/app.py +22 -0
  17. envctl/cli/commands/doctor/__init__.py +5 -0
  18. envctl/cli/commands/doctor/command.py +55 -0
  19. envctl/cli/commands/explain/__init__.py +5 -0
  20. envctl/cli/commands/explain/command.py +40 -0
  21. envctl/cli/commands/export/__init__.py +5 -0
  22. envctl/cli/commands/export/command.py +18 -0
  23. envctl/cli/commands/fill/__init__.py +5 -0
  24. envctl/cli/commands/fill/command.py +49 -0
  25. envctl/cli/commands/init/__init__.py +5 -0
  26. envctl/cli/commands/init/command.py +50 -0
  27. envctl/cli/commands/inspect/__init__.py +5 -0
  28. envctl/cli/commands/inspect/command.py +36 -0
  29. envctl/cli/commands/profile/__init__.py +5 -0
  30. envctl/cli/commands/profile/app.py +19 -0
  31. envctl/cli/commands/profile/commands/__init__.py +1 -0
  32. envctl/cli/commands/profile/commands/copy.py +34 -0
  33. envctl/cli/commands/profile/commands/create.py +33 -0
  34. envctl/cli/commands/profile/commands/list.py +18 -0
  35. envctl/cli/commands/profile/commands/path.py +26 -0
  36. envctl/cli/commands/profile/commands/remove.py +49 -0
  37. envctl/cli/commands/project/__init__.py +5 -0
  38. envctl/cli/commands/project/app.py +19 -0
  39. envctl/cli/commands/project/commands/__init__.py +13 -0
  40. envctl/cli/commands/project/commands/bind.py +31 -0
  41. envctl/cli/commands/project/commands/rebind.py +47 -0
  42. envctl/cli/commands/project/commands/repair.py +45 -0
  43. envctl/cli/commands/project/commands/unbind.py +24 -0
  44. envctl/cli/commands/remove/__init__.py +5 -0
  45. envctl/cli/commands/remove/command.py +65 -0
  46. envctl/cli/commands/run/__init__.py +5 -0
  47. envctl/cli/commands/run/command.py +22 -0
  48. envctl/cli/commands/set/__init__.py +5 -0
  49. envctl/cli/commands/set/command.py +28 -0
  50. envctl/cli/commands/status/__init__.py +5 -0
  51. envctl/cli/commands/status/command.py +31 -0
  52. envctl/cli/commands/sync/__init__.py +5 -0
  53. envctl/cli/commands/sync/command.py +19 -0
  54. envctl/cli/commands/unset/__init__.py +5 -0
  55. envctl/cli/commands/unset/command.py +30 -0
  56. envctl/cli/commands/vault/__init__.py +5 -0
  57. envctl/cli/commands/vault/app.py +21 -0
  58. envctl/cli/commands/vault/commands/__init__.py +15 -0
  59. envctl/cli/commands/vault/commands/check.py +43 -0
  60. envctl/cli/commands/vault/commands/edit.py +34 -0
  61. envctl/cli/commands/vault/commands/path.py +17 -0
  62. envctl/cli/commands/vault/commands/prune.py +58 -0
  63. envctl/cli/commands/vault/commands/show.py +82 -0
  64. envctl/cli/decorators.py +74 -0
  65. envctl/cli/formatters.py +78 -0
  66. envctl/cli/runtime.py +75 -0
  67. envctl/cli/serializers.py +120 -0
  68. envctl/config/__init__.py +1 -0
  69. envctl/config/defaults.py +46 -0
  70. envctl/config/loader.py +151 -0
  71. envctl/config/writer.py +39 -0
  72. envctl/constants.py +30 -0
  73. envctl/domain/__init__.py +1 -0
  74. envctl/domain/app_config.py +26 -0
  75. envctl/domain/contract.py +235 -0
  76. envctl/domain/contract_inference.py +236 -0
  77. envctl/domain/doctor.py +14 -0
  78. envctl/domain/operations.py +191 -0
  79. envctl/domain/project.py +35 -0
  80. envctl/domain/resolution.py +51 -0
  81. envctl/domain/runtime.py +19 -0
  82. envctl/domain/status.py +21 -0
  83. envctl/errors.py +33 -0
  84. envctl/repository/__init__.py +1 -0
  85. envctl/repository/contract_repository.py +120 -0
  86. envctl/repository/project_context.py +327 -0
  87. envctl/repository/state_repository.py +186 -0
  88. envctl/services/__init__.py +1 -0
  89. envctl/services/add_service.py +157 -0
  90. envctl/services/bind_service.py +46 -0
  91. envctl/services/check_service.py +20 -0
  92. envctl/services/config_service.py +12 -0
  93. envctl/services/context_service.py +29 -0
  94. envctl/services/doctor_service.py +177 -0
  95. envctl/services/explain_service.py +28 -0
  96. envctl/services/export_service.py +35 -0
  97. envctl/services/fill_service.py +72 -0
  98. envctl/services/init_service.py +210 -0
  99. envctl/services/inspect_service.py +20 -0
  100. envctl/services/profile_service.py +162 -0
  101. envctl/services/rebind_service.py +71 -0
  102. envctl/services/remove_service.py +98 -0
  103. envctl/services/repair_service.py +123 -0
  104. envctl/services/resolution_service.py +146 -0
  105. envctl/services/run_service.py +56 -0
  106. envctl/services/set_service.py +35 -0
  107. envctl/services/status_service.py +97 -0
  108. envctl/services/sync_service.py +40 -0
  109. envctl/services/unbind_service.py +32 -0
  110. envctl/services/unset_service.py +35 -0
  111. envctl/services/vault_service.py +181 -0
  112. envctl/utils/__init__.py +1 -0
  113. envctl/utils/atomic.py +57 -0
  114. envctl/utils/filesystem.py +40 -0
  115. envctl/utils/masking.py +12 -0
  116. envctl/utils/output.py +25 -0
  117. envctl/utils/project_ids.py +20 -0
  118. envctl/utils/project_names.py +22 -0
  119. envctl/utils/project_paths.py +46 -0
  120. envctl/utils/shells.py +18 -0
  121. envctl/utils/tilde.py +16 -0
  122. envctl-2.3.1.dist-info/METADATA +302 -0
  123. envctl-2.3.1.dist-info/RECORD +127 -0
  124. envctl-2.3.1.dist-info/WHEEL +5 -0
  125. envctl-2.3.1.dist-info/entry_points.txt +2 -0
  126. envctl-2.3.1.dist-info/licenses/LICENSE +21 -0
  127. envctl-2.3.1.dist-info/top_level.txt +1 -0
envctl/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """envctl package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ try:
8
+ __version__ = version("envctl")
9
+ except PackageNotFoundError:
10
+ __version__ = "0.0.0-dev"
envctl/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entrypoint for `python -m envctl`."""
2
+
3
+ from envctl.cli.app import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
File without changes
@@ -0,0 +1,76 @@
1
+ """Helpers for reading and writing dotenv-style files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def parse_env_text(content: str) -> dict[str, str]:
9
+ """Parse a dotenv-like text payload into a mapping."""
10
+ result: dict[str, str] = {}
11
+ for raw_line in content.splitlines():
12
+ line = raw_line.strip()
13
+ if not line or line.startswith("#"):
14
+ continue
15
+ if "=" not in line:
16
+ continue
17
+
18
+ key, value = line.split("=", 1)
19
+ key = key.strip()
20
+ value = value.strip()
21
+
22
+ if not key:
23
+ continue
24
+
25
+ if len(value) >= 2 and (
26
+ (value.startswith('"') and value.endswith('"'))
27
+ or (value.startswith("'") and value.endswith("'"))
28
+ ):
29
+ value = value[1:-1]
30
+
31
+ result[key] = value
32
+
33
+ return result
34
+
35
+
36
+ def load_env_file(path: Path) -> dict[str, str]:
37
+ """Load a dotenv file when present."""
38
+ if not path.exists():
39
+ return {}
40
+ return parse_env_text(path.read_text(encoding="utf-8"))
41
+
42
+
43
+ def _needs_quotes(value: str) -> bool:
44
+ """Return whether a dotenv value should be quoted."""
45
+ if value == "":
46
+ return True
47
+
48
+ special_chars = {" ", "\t", "\n", "\r", "#", '"', "'"}
49
+ return any(char in value for char in special_chars)
50
+
51
+
52
+ def _dump_env_value(value: str) -> str:
53
+ """Serialize one dotenv value safely for shell consumption."""
54
+ if not _needs_quotes(value):
55
+ return value
56
+
57
+ escaped = (
58
+ value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
59
+ )
60
+ return f'"{escaped}"'
61
+
62
+
63
+ def dump_env(data: dict[str, str], header: str | None = None) -> str:
64
+ """Dump a mapping to dotenv text."""
65
+ lines: list[str] = []
66
+
67
+ if header:
68
+ lines.append(header.rstrip("\n"))
69
+ lines.append("")
70
+
71
+ for key in sorted(data):
72
+ value = data[key]
73
+ lines.append(f"{key}={_dump_env_value(value)}")
74
+
75
+ lines.append("")
76
+ return "\n".join(lines)
@@ -0,0 +1,39 @@
1
+ # src/envctl/adapters/editor.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+
10
+ from envctl.errors import ExecutionError
11
+
12
+
13
+ def resolve_editor() -> list[str]:
14
+ visual = os.environ.get("VISUAL", "").strip()
15
+ if visual:
16
+ return shlex.split(visual)
17
+
18
+ editor = os.environ.get("EDITOR", "").strip()
19
+ if editor:
20
+ return shlex.split(editor)
21
+
22
+ for candidate in ("nano", "vi"):
23
+ path = shutil.which(candidate)
24
+ if path:
25
+ return [path]
26
+
27
+ raise ExecutionError("No editor found. Set VISUAL or EDITOR.")
28
+
29
+
30
+ def open_file(path: str) -> None:
31
+ command = [*resolve_editor(), path]
32
+
33
+ try:
34
+ completed = subprocess.run(command, check=False)
35
+ except OSError as exc:
36
+ raise ExecutionError(f"Failed to launch editor: {command[0]}") from exc
37
+
38
+ if completed.returncode != 0:
39
+ raise ExecutionError(f"Editor exited with code {completed.returncode}")
envctl/adapters/git.py ADDED
@@ -0,0 +1,76 @@
1
+ """Git repository helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from envctl.errors import ExecutionError, ProjectDetectionError
9
+
10
+
11
+ def _run_git(
12
+ args: list[str],
13
+ *,
14
+ cwd: Path | None = None,
15
+ check: bool = True,
16
+ ) -> str:
17
+ """Run a git command and return stripped stdout."""
18
+ try:
19
+ completed = subprocess.run(
20
+ ["git", *args],
21
+ cwd=str(cwd) if cwd else None,
22
+ check=check,
23
+ capture_output=True,
24
+ text=True,
25
+ )
26
+ except FileNotFoundError as exc:
27
+ raise ProjectDetectionError("git executable not found") from exc
28
+ except subprocess.CalledProcessError as exc:
29
+ message = (exc.stderr or "").strip() or (exc.stdout or "").strip() or "git command failed"
30
+
31
+ if not check:
32
+ return ""
33
+
34
+ raise ProjectDetectionError(message) from exc
35
+
36
+ return completed.stdout.strip()
37
+
38
+
39
+ def resolve_repo_root() -> Path:
40
+ """Resolve the current Git repository root."""
41
+ return Path(_run_git(["rev-parse", "--show-toplevel"])).resolve()
42
+
43
+
44
+ def get_repo_remote(repo_root: Path) -> str | None:
45
+ """Return the origin remote URL when available."""
46
+ try:
47
+ value = _run_git(["remote", "get-url", "origin"], cwd=repo_root, check=False)
48
+ except ProjectDetectionError:
49
+ return None
50
+
51
+ return value or None
52
+
53
+
54
+ def get_local_git_config(repo_root: Path, key: str) -> str | None:
55
+ """Return one local Git config value when present."""
56
+ value = _run_git(["config", "--local", "--get", key], cwd=repo_root, check=False)
57
+ return value or None
58
+
59
+
60
+ def set_local_git_config(repo_root: Path, key: str, value: str) -> None:
61
+ """Persist one local Git config value."""
62
+ try:
63
+ _run_git(["config", "--local", key, value], cwd=repo_root, check=True)
64
+ except ProjectDetectionError as exc:
65
+ raise ExecutionError(str(exc)) from exc
66
+
67
+
68
+ def unset_local_git_config(repo_root: Path, key: str) -> None:
69
+ """Remove one local Git config value when present."""
70
+ try:
71
+ _run_git(["config", "--local", "--unset", key], cwd=repo_root, check=True)
72
+ except ProjectDetectionError as exc:
73
+ message = str(exc).strip().lower()
74
+ if "no such section or key" in message or "key does not contain a section" in message:
75
+ return
76
+ raise ExecutionError(str(exc)) from exc
envctl/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI package."""
envctl/cli/app.py ADDED
@@ -0,0 +1,92 @@
1
+ """Main Typer application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from envctl.cli.callbacks import version_callback
8
+ from envctl.cli.commands.add import add_command
9
+ from envctl.cli.commands.check import check_command
10
+ from envctl.cli.commands.config import config_app
11
+ from envctl.cli.commands.doctor import doctor_command
12
+ from envctl.cli.commands.explain import explain_command
13
+ from envctl.cli.commands.export import export_command
14
+ from envctl.cli.commands.fill import fill_command
15
+ from envctl.cli.commands.init import init_command
16
+ from envctl.cli.commands.inspect import inspect_command
17
+ from envctl.cli.commands.profile import profile_app
18
+ from envctl.cli.commands.project import project_app
19
+ from envctl.cli.commands.remove import remove_command
20
+ from envctl.cli.commands.run import run_command_cli
21
+ from envctl.cli.commands.set import set_command
22
+ from envctl.cli.commands.status import status_command
23
+ from envctl.cli.commands.sync import sync_command
24
+ from envctl.cli.commands.unset import unset_command
25
+ from envctl.cli.commands.vault import vault_app
26
+ from envctl.cli.runtime import set_cli_state
27
+ from envctl.config.loader import resolve_default_profile
28
+ from envctl.domain.runtime import OutputFormat
29
+
30
+ VERSION_OPTION = typer.Option(
31
+ None,
32
+ "--version",
33
+ "-V",
34
+ help="Show the version and exit.",
35
+ callback=version_callback,
36
+ is_eager=True,
37
+ )
38
+ JSON_OPTION = typer.Option(
39
+ False,
40
+ "--json",
41
+ help="Emit structured JSON output for supported commands.",
42
+ )
43
+ PROFILE_OPTION = typer.Option(
44
+ None,
45
+ "--profile",
46
+ "-p",
47
+ help="Select the active environment profile.",
48
+ )
49
+
50
+ app = typer.Typer(help="envctl - local environment control plane")
51
+ app.add_typer(config_app, name="config")
52
+ app.add_typer(vault_app, name="vault")
53
+ app.add_typer(project_app, name="project")
54
+ app.add_typer(profile_app, name="profile")
55
+
56
+
57
+ @app.callback()
58
+ def main(
59
+ ctx: typer.Context,
60
+ version: bool = VERSION_OPTION,
61
+ json_output: bool = JSON_OPTION,
62
+ profile: str | None = PROFILE_OPTION,
63
+ ) -> None:
64
+ """envctl - local environment control plane."""
65
+ del version
66
+
67
+ active_profile = profile.strip().lower() if profile is not None else resolve_default_profile()
68
+
69
+ set_cli_state(
70
+ ctx,
71
+ output_format=OutputFormat.JSON if json_output else OutputFormat.TEXT,
72
+ profile=active_profile,
73
+ )
74
+
75
+
76
+ app.command("doctor")(doctor_command)
77
+ app.command("init")(init_command)
78
+ app.command("add")(add_command)
79
+ app.command("set")(set_command)
80
+ app.command("unset")(unset_command)
81
+ app.command("remove")(remove_command)
82
+ app.command("fill")(fill_command)
83
+ app.command("check")(check_command)
84
+ app.command("inspect")(inspect_command)
85
+ app.command("explain")(explain_command)
86
+ app.command("sync")(sync_command)
87
+ app.command("export")(export_command)
88
+ app.command(
89
+ "run",
90
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
91
+ )(run_command_cli)
92
+ app.command("status")(status_command)
@@ -0,0 +1,33 @@
1
+ """CLI callbacks and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+
7
+ import typer
8
+
9
+ from envctl import __version__
10
+
11
+
12
+ def version_callback(value: bool) -> None:
13
+ """Print the version and exit."""
14
+ if value:
15
+ typer.echo(f"envctl {__version__}")
16
+ raise typer.Exit()
17
+
18
+
19
+ def typer_prompt(message: str, secret: bool, default: str | None) -> str:
20
+ """Prompt for a string value from the terminal."""
21
+ suffix = f" [{default}]" if default is not None else ""
22
+ full_message = f"{message}{suffix}"
23
+
24
+ if secret:
25
+ value = getpass.getpass(f"{full_message}: ")
26
+ return value if value else (default or "")
27
+
28
+ return str(typer.prompt(full_message, default=default or "", show_default=False))
29
+
30
+
31
+ def typer_confirm(message: str, default: bool = False) -> bool:
32
+ """Prompt for a boolean confirmation from the terminal."""
33
+ return typer.confirm(message, default=default, show_default=True)
@@ -0,0 +1 @@
1
+ """CLI commands."""
@@ -0,0 +1,5 @@
1
+ """Add command package."""
2
+
3
+ from envctl.cli.commands.add.command import add_command
4
+
5
+ __all__ = ["add_command"]
@@ -0,0 +1,237 @@
1
+ """Add command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from envctl.cli.callbacks import typer_confirm, typer_prompt
8
+ from envctl.cli.decorators import (
9
+ handle_errors,
10
+ requires_writable_runtime,
11
+ text_output_only,
12
+ )
13
+ from envctl.cli.runtime import get_active_profile
14
+ from envctl.domain.operations import AddVariableRequest
15
+ from envctl.services.add_service import run_add
16
+ from envctl.utils.output import print_kv, print_success, print_warning
17
+
18
+ KEY_ARGUMENT = typer.Argument(...)
19
+ VALUE_ARGUMENT = typer.Argument(...)
20
+ TYPE_OPTION = typer.Option(None, "--type")
21
+ REQUIRED_OPTION = typer.Option(False, "--required")
22
+ OPTIONAL_OPTION = typer.Option(False, "--optional")
23
+ SENSITIVE_OPTION = typer.Option(False, "--sensitive")
24
+ NON_SENSITIVE_OPTION = typer.Option(False, "--non-sensitive")
25
+ INTERACTIVE_OPTION = typer.Option(False, "--interactive")
26
+ DESCRIPTION_OPTION = typer.Option(None, "--description")
27
+ DEFAULT_OPTION = typer.Option(None, "--default")
28
+ EXAMPLE_OPTION = typer.Option(None, "--example")
29
+ PATTERN_OPTION = typer.Option(None, "--pattern")
30
+ CHOICE_OPTION = typer.Option(None, "--choice")
31
+
32
+
33
+ def _resolve_required(required: bool, optional: bool) -> bool | None:
34
+ """Resolve required/optional flags."""
35
+ if required and optional:
36
+ raise typer.BadParameter("Use either --required or --optional, not both.")
37
+ if required:
38
+ return True
39
+ if optional:
40
+ return False
41
+ return None
42
+
43
+
44
+ def _resolve_sensitive(sensitive: bool, non_sensitive: bool) -> bool | None:
45
+ """Resolve sensitive/non-sensitive flags."""
46
+ if sensitive and non_sensitive:
47
+ raise typer.BadParameter("Use either --sensitive or --non-sensitive, not both.")
48
+ if sensitive:
49
+ return True
50
+ if non_sensitive:
51
+ return False
52
+ return None
53
+
54
+
55
+ def _collect_interactive_overrides(
56
+ *,
57
+ type_: str | None,
58
+ description: str | None,
59
+ override_required: bool | None,
60
+ override_sensitive: bool | None,
61
+ default: str | None,
62
+ example: str | None,
63
+ pattern: str | None,
64
+ choice: list[str] | None,
65
+ ) -> tuple[
66
+ str | None,
67
+ str | None,
68
+ bool | None,
69
+ bool | None,
70
+ str | None,
71
+ str | None,
72
+ str | None,
73
+ list[str],
74
+ ]:
75
+ """Collect interactive metadata overrides."""
76
+ resolved_type = typer_prompt("Variable type", False, type_ or "string")
77
+ resolved_description = typer_prompt("Description", False, description or "")
78
+
79
+ resolved_required = override_required
80
+ if resolved_required is None:
81
+ resolved_required = typer_confirm("Required?", default=True)
82
+
83
+ resolved_sensitive = override_sensitive
84
+ if resolved_sensitive is None:
85
+ resolved_sensitive = typer_confirm("Sensitive?", default=False)
86
+
87
+ resolved_default = typer_prompt("Default value", False, default or "")
88
+ resolved_example = typer_prompt("Example", False, example or "")
89
+ resolved_pattern = typer_prompt("Pattern", False, pattern or "")
90
+ choices_text = typer_prompt(
91
+ "Choices (comma-separated)",
92
+ False,
93
+ ", ".join(choice or []),
94
+ )
95
+ resolved_choices = [item.strip() for item in choices_text.split(",") if item.strip()]
96
+
97
+ return (
98
+ resolved_type,
99
+ resolved_description,
100
+ resolved_required,
101
+ resolved_sensitive,
102
+ resolved_default,
103
+ resolved_example,
104
+ resolved_pattern,
105
+ resolved_choices,
106
+ )
107
+
108
+
109
+ def _build_add_request(
110
+ *,
111
+ key: str,
112
+ value: str,
113
+ type_: str | None,
114
+ required: bool,
115
+ optional: bool,
116
+ sensitive: bool,
117
+ non_sensitive: bool,
118
+ interactive: bool,
119
+ description: str | None,
120
+ default: str | None,
121
+ example: str | None,
122
+ pattern: str | None,
123
+ choice: list[str] | None,
124
+ ) -> AddVariableRequest:
125
+ """Build the add request payload."""
126
+ override_required = _resolve_required(required, optional)
127
+ override_sensitive = _resolve_sensitive(sensitive, non_sensitive)
128
+
129
+ resolved_type = type_
130
+ resolved_description = description
131
+ resolved_default = default
132
+ resolved_example = example
133
+ resolved_pattern = pattern
134
+ resolved_choice = choice or []
135
+
136
+ if interactive:
137
+ (
138
+ resolved_type,
139
+ resolved_description,
140
+ override_required,
141
+ override_sensitive,
142
+ resolved_default,
143
+ resolved_example,
144
+ resolved_pattern,
145
+ resolved_choice,
146
+ ) = _collect_interactive_overrides(
147
+ type_=type_,
148
+ description=description,
149
+ override_required=override_required,
150
+ override_sensitive=override_sensitive,
151
+ default=default,
152
+ example=example,
153
+ pattern=pattern,
154
+ choice=choice,
155
+ )
156
+
157
+ return AddVariableRequest(
158
+ key=key,
159
+ value=value,
160
+ override_type=resolved_type,
161
+ override_required=override_required,
162
+ override_sensitive=override_sensitive,
163
+ override_description=resolved_description,
164
+ override_default=resolved_default or None,
165
+ override_example=resolved_example or None,
166
+ override_pattern=resolved_pattern or None,
167
+ override_choices=tuple(resolved_choice),
168
+ )
169
+
170
+
171
+ def _render_inferred_spec(inferred_spec: dict[str, object] | None) -> None:
172
+ """Render inferred metadata details when present."""
173
+ if inferred_spec is None:
174
+ return
175
+
176
+ if "type" in inferred_spec:
177
+ print_kv("inferred_type", str(inferred_spec["type"]))
178
+ if "required" in inferred_spec:
179
+ print_kv("required", "yes" if inferred_spec["required"] else "no")
180
+ if "sensitive" in inferred_spec:
181
+ print_kv("sensitive", "yes" if inferred_spec["sensitive"] else "no")
182
+ if "description" in inferred_spec:
183
+ print_kv("description", str(inferred_spec["description"]))
184
+
185
+ print_warning("Review .envctl.schema.yaml to confirm the inferred metadata.")
186
+
187
+
188
+ @handle_errors
189
+ @requires_writable_runtime("add")
190
+ @text_output_only("add")
191
+ def add_command(
192
+ key: str = KEY_ARGUMENT,
193
+ value: str = VALUE_ARGUMENT,
194
+ type_: str | None = TYPE_OPTION,
195
+ required: bool = REQUIRED_OPTION,
196
+ optional: bool = OPTIONAL_OPTION,
197
+ sensitive: bool = SENSITIVE_OPTION,
198
+ non_sensitive: bool = NON_SENSITIVE_OPTION,
199
+ interactive: bool = INTERACTIVE_OPTION,
200
+ description: str | None = DESCRIPTION_OPTION,
201
+ default: str | None = DEFAULT_OPTION,
202
+ example: str | None = EXAMPLE_OPTION,
203
+ pattern: str | None = PATTERN_OPTION,
204
+ choice: list[str] | None = CHOICE_OPTION,
205
+ ) -> None:
206
+ """Add one variable to the contract and store its initial value in the active profile."""
207
+ request = _build_add_request(
208
+ key=key,
209
+ value=value,
210
+ type_=type_,
211
+ required=required,
212
+ optional=optional,
213
+ sensitive=sensitive,
214
+ non_sensitive=non_sensitive,
215
+ interactive=interactive,
216
+ description=description,
217
+ default=default,
218
+ example=example,
219
+ pattern=pattern,
220
+ choice=choice,
221
+ )
222
+
223
+ context, result = run_add(request, get_active_profile())
224
+
225
+ print_success(f"Added '{key}' to contract and profile '{result.active_profile}'")
226
+ print_kv("profile", result.active_profile)
227
+ print_kv("vault_values", str(result.profile_path))
228
+ print_kv("contract", str(context.repo_contract_path))
229
+
230
+ if result.contract_created:
231
+ print_kv("contract_created", "yes")
232
+ if result.contract_updated:
233
+ print_kv("contract_updated", "yes")
234
+ if result.contract_entry_created:
235
+ print_kv("contract_entry_created", "yes")
236
+
237
+ _render_inferred_spec(result.inferred_spec)
@@ -0,0 +1,5 @@
1
+ """Check command package."""
2
+
3
+ from envctl.cli.commands.check.command import check_command
4
+
5
+ __all__ = ["check_command"]
@@ -0,0 +1,49 @@
1
+ """Check command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from envctl.cli.decorators import handle_errors
8
+ from envctl.cli.formatters import render_resolution
9
+ from envctl.cli.runtime import get_active_profile, is_json_output
10
+ from envctl.cli.serializers import emit_json, serialize_project_context, serialize_resolution_report
11
+ from envctl.services.check_service import run_check
12
+ from envctl.utils.output import print_kv, print_success, print_warning
13
+
14
+
15
+ @handle_errors
16
+ def check_command() -> None:
17
+ """Validate the current project environment against the contract."""
18
+ context, active_profile, report = run_check(get_active_profile())
19
+
20
+ if is_json_output():
21
+ ok = report.is_valid and not report.unknown_keys
22
+ emit_json(
23
+ {
24
+ "ok": ok,
25
+ "command": "check",
26
+ "data": {
27
+ "active_profile": active_profile,
28
+ "context": serialize_project_context(context),
29
+ "report": serialize_resolution_report(report),
30
+ },
31
+ }
32
+ )
33
+ if not ok:
34
+ raise typer.Exit(code=1)
35
+ return
36
+
37
+ print_kv("profile", active_profile)
38
+ typer.echo()
39
+ render_resolution(report)
40
+
41
+ if report.is_valid and not report.unknown_keys:
42
+ print_success("Environment contract satisfied")
43
+ return
44
+
45
+ if report.is_valid:
46
+ print_warning("Environment is valid, but the vault contains unknown keys")
47
+ raise typer.Exit(code=1)
48
+
49
+ raise typer.Exit(code=1)
@@ -0,0 +1,5 @@
1
+ """Config command package."""
2
+
3
+ from envctl.cli.commands.config.app import config_app
4
+
5
+ __all__ = ["config_app"]
@@ -0,0 +1,22 @@
1
+ """Config commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from envctl.cli.decorators import handle_errors, requires_writable_runtime, text_output_only
8
+ from envctl.services.config_service import run_config_init
9
+ from envctl.utils.output import print_kv, print_success
10
+
11
+ config_app = typer.Typer(help="Manage envctl configuration.")
12
+
13
+
14
+ @config_app.command("init")
15
+ @handle_errors
16
+ @requires_writable_runtime("config init")
17
+ @text_output_only("config init")
18
+ def config_init() -> None:
19
+ """Create the default envctl config file."""
20
+ config_path = run_config_init()
21
+ print_success("Created envctl config file")
22
+ print_kv("config", str(config_path))
@@ -0,0 +1,5 @@
1
+ """Doctor command package."""
2
+
3
+ from envctl.cli.commands.doctor.command import doctor_command
4
+
5
+ __all__ = ["doctor_command"]