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.
- envctl/__init__.py +10 -0
- envctl/__main__.py +6 -0
- envctl/adapters/__init__.py +0 -0
- envctl/adapters/dotenv.py +76 -0
- envctl/adapters/editor.py +39 -0
- envctl/adapters/git.py +76 -0
- envctl/cli/__init__.py +1 -0
- envctl/cli/app.py +92 -0
- envctl/cli/callbacks.py +33 -0
- envctl/cli/commands/__init__.py +1 -0
- envctl/cli/commands/add/__init__.py +5 -0
- envctl/cli/commands/add/command.py +237 -0
- envctl/cli/commands/check/__init__.py +5 -0
- envctl/cli/commands/check/command.py +49 -0
- envctl/cli/commands/config/__init__.py +5 -0
- envctl/cli/commands/config/app.py +22 -0
- envctl/cli/commands/doctor/__init__.py +5 -0
- envctl/cli/commands/doctor/command.py +55 -0
- envctl/cli/commands/explain/__init__.py +5 -0
- envctl/cli/commands/explain/command.py +40 -0
- envctl/cli/commands/export/__init__.py +5 -0
- envctl/cli/commands/export/command.py +18 -0
- envctl/cli/commands/fill/__init__.py +5 -0
- envctl/cli/commands/fill/command.py +49 -0
- envctl/cli/commands/init/__init__.py +5 -0
- envctl/cli/commands/init/command.py +50 -0
- envctl/cli/commands/inspect/__init__.py +5 -0
- envctl/cli/commands/inspect/command.py +36 -0
- envctl/cli/commands/profile/__init__.py +5 -0
- envctl/cli/commands/profile/app.py +19 -0
- envctl/cli/commands/profile/commands/__init__.py +1 -0
- envctl/cli/commands/profile/commands/copy.py +34 -0
- envctl/cli/commands/profile/commands/create.py +33 -0
- envctl/cli/commands/profile/commands/list.py +18 -0
- envctl/cli/commands/profile/commands/path.py +26 -0
- envctl/cli/commands/profile/commands/remove.py +49 -0
- envctl/cli/commands/project/__init__.py +5 -0
- envctl/cli/commands/project/app.py +19 -0
- envctl/cli/commands/project/commands/__init__.py +13 -0
- envctl/cli/commands/project/commands/bind.py +31 -0
- envctl/cli/commands/project/commands/rebind.py +47 -0
- envctl/cli/commands/project/commands/repair.py +45 -0
- envctl/cli/commands/project/commands/unbind.py +24 -0
- envctl/cli/commands/remove/__init__.py +5 -0
- envctl/cli/commands/remove/command.py +65 -0
- envctl/cli/commands/run/__init__.py +5 -0
- envctl/cli/commands/run/command.py +22 -0
- envctl/cli/commands/set/__init__.py +5 -0
- envctl/cli/commands/set/command.py +28 -0
- envctl/cli/commands/status/__init__.py +5 -0
- envctl/cli/commands/status/command.py +31 -0
- envctl/cli/commands/sync/__init__.py +5 -0
- envctl/cli/commands/sync/command.py +19 -0
- envctl/cli/commands/unset/__init__.py +5 -0
- envctl/cli/commands/unset/command.py +30 -0
- envctl/cli/commands/vault/__init__.py +5 -0
- envctl/cli/commands/vault/app.py +21 -0
- envctl/cli/commands/vault/commands/__init__.py +15 -0
- envctl/cli/commands/vault/commands/check.py +43 -0
- envctl/cli/commands/vault/commands/edit.py +34 -0
- envctl/cli/commands/vault/commands/path.py +17 -0
- envctl/cli/commands/vault/commands/prune.py +58 -0
- envctl/cli/commands/vault/commands/show.py +82 -0
- envctl/cli/decorators.py +74 -0
- envctl/cli/formatters.py +78 -0
- envctl/cli/runtime.py +75 -0
- envctl/cli/serializers.py +120 -0
- envctl/config/__init__.py +1 -0
- envctl/config/defaults.py +46 -0
- envctl/config/loader.py +151 -0
- envctl/config/writer.py +39 -0
- envctl/constants.py +30 -0
- envctl/domain/__init__.py +1 -0
- envctl/domain/app_config.py +26 -0
- envctl/domain/contract.py +235 -0
- envctl/domain/contract_inference.py +236 -0
- envctl/domain/doctor.py +14 -0
- envctl/domain/operations.py +191 -0
- envctl/domain/project.py +35 -0
- envctl/domain/resolution.py +51 -0
- envctl/domain/runtime.py +19 -0
- envctl/domain/status.py +21 -0
- envctl/errors.py +33 -0
- envctl/repository/__init__.py +1 -0
- envctl/repository/contract_repository.py +120 -0
- envctl/repository/project_context.py +327 -0
- envctl/repository/state_repository.py +186 -0
- envctl/services/__init__.py +1 -0
- envctl/services/add_service.py +157 -0
- envctl/services/bind_service.py +46 -0
- envctl/services/check_service.py +20 -0
- envctl/services/config_service.py +12 -0
- envctl/services/context_service.py +29 -0
- envctl/services/doctor_service.py +177 -0
- envctl/services/explain_service.py +28 -0
- envctl/services/export_service.py +35 -0
- envctl/services/fill_service.py +72 -0
- envctl/services/init_service.py +210 -0
- envctl/services/inspect_service.py +20 -0
- envctl/services/profile_service.py +162 -0
- envctl/services/rebind_service.py +71 -0
- envctl/services/remove_service.py +98 -0
- envctl/services/repair_service.py +123 -0
- envctl/services/resolution_service.py +146 -0
- envctl/services/run_service.py +56 -0
- envctl/services/set_service.py +35 -0
- envctl/services/status_service.py +97 -0
- envctl/services/sync_service.py +40 -0
- envctl/services/unbind_service.py +32 -0
- envctl/services/unset_service.py +35 -0
- envctl/services/vault_service.py +181 -0
- envctl/utils/__init__.py +1 -0
- envctl/utils/atomic.py +57 -0
- envctl/utils/filesystem.py +40 -0
- envctl/utils/masking.py +12 -0
- envctl/utils/output.py +25 -0
- envctl/utils/project_ids.py +20 -0
- envctl/utils/project_names.py +22 -0
- envctl/utils/project_paths.py +46 -0
- envctl/utils/shells.py +18 -0
- envctl/utils/tilde.py +16 -0
- envctl-2.3.1.dist-info/METADATA +302 -0
- envctl-2.3.1.dist-info/RECORD +127 -0
- envctl-2.3.1.dist-info/WHEEL +5 -0
- envctl-2.3.1.dist-info/entry_points.txt +2 -0
- envctl-2.3.1.dist-info/licenses/LICENSE +21 -0
- envctl-2.3.1.dist-info/top_level.txt +1 -0
envctl/__init__.py
ADDED
envctl/__main__.py
ADDED
|
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)
|
envctl/cli/callbacks.py
ADDED
|
@@ -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,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,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,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))
|