quickscale-cli 0.76.0__tar.gz → 0.83.0__tar.gz

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 (42) hide show
  1. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/PKG-INFO +5 -4
  2. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/README.md +2 -1
  3. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/pyproject.toml +5 -4
  4. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/__init__.py +8 -1
  5. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/_version.py +1 -1
  6. quickscale_cli-0.83.0/src/quickscale_cli/analytics_contract.py +177 -0
  7. quickscale_cli-0.83.0/src/quickscale_cli/auth_contract.py +97 -0
  8. quickscale_cli-0.83.0/src/quickscale_cli/backups_contract.py +137 -0
  9. quickscale_cli-0.83.0/src/quickscale_cli/beta_migration.py +2451 -0
  10. quickscale_cli-0.83.0/src/quickscale_cli/commands/apply_command.py +2349 -0
  11. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/commands/development_commands.py +14 -3
  12. quickscale_cli-0.83.0/src/quickscale_cli/commands/dr_commands.py +1319 -0
  13. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/commands/module_commands.py +295 -57
  14. quickscale_cli-0.83.0/src/quickscale_cli/commands/module_config.py +1879 -0
  15. quickscale_cli-0.83.0/src/quickscale_cli/commands/module_wiring_specs.py +646 -0
  16. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/commands/plan_command.py +183 -43
  17. quickscale_cli-0.83.0/src/quickscale_cli/commands/remove_command.py +704 -0
  18. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/commands/status_command.py +127 -27
  19. quickscale_cli-0.83.0/src/quickscale_cli/crm_contract.py +43 -0
  20. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/main.py +2 -0
  21. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/module_catalog.py +44 -0
  22. quickscale_cli-0.83.0/src/quickscale_cli/notifications_contract.py +306 -0
  23. quickscale_cli-0.83.0/src/quickscale_cli/schema/__init__.py +56 -0
  24. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/schema/config_schema.py +141 -5
  25. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/schema/state_schema.py +18 -4
  26. quickscale_cli-0.83.0/src/quickscale_cli/social_contract.py +483 -0
  27. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/utils/dependency_utils.py +5 -1
  28. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/utils/docker_utils.py +12 -4
  29. quickscale_cli-0.83.0/src/quickscale_cli/utils/module_dependency_sync.py +364 -0
  30. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/utils/module_wiring_manager.py +38 -11
  31. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/utils/project_identity.py +54 -12
  32. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/utils/project_manager.py +8 -2
  33. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/utils/railway_utils.py +80 -16
  34. quickscale_cli-0.76.0/src/quickscale_cli/commands/apply_command.py +0 -1045
  35. quickscale_cli-0.76.0/src/quickscale_cli/commands/module_config.py +0 -1334
  36. quickscale_cli-0.76.0/src/quickscale_cli/commands/module_wiring_specs.py +0 -291
  37. quickscale_cli-0.76.0/src/quickscale_cli/commands/remove_command.py +0 -264
  38. quickscale_cli-0.76.0/src/quickscale_cli/schema/__init__.py +0 -40
  39. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/commands/__init__.py +0 -0
  40. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/commands/deployment_commands.py +0 -0
  41. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/schema/delta.py +0 -0
  42. {quickscale_cli-0.76.0 → quickscale_cli-0.83.0}/src/quickscale_cli/utils/__init__.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickscale-cli
3
- Version: 0.76.0
3
+ Version: 0.83.0
4
4
  Summary: Command-line interface for QuickScale Django project generator
5
5
  License-Expression: Apache-2.0
6
6
  Keywords: django,cli,project-generator,saas
7
7
  Author: Experto AI
8
8
  Author-email: victor@experto.ai
9
- Requires-Python: >=3.14,<4.0
9
+ Requires-Python: >=3.12,<3.14
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Programming Language :: Python :: 3
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.14
14
14
  Classifier: Framework :: Django
15
15
  Classifier: Framework :: Django :: 6.0
16
16
  Requires-Dist: click (>=8.3.1,<9.0.0)
17
- Requires-Dist: quickscale-core (>=0.76.0,<0.77.0)
17
+ Requires-Dist: quickscale-core (>=0.83.0,<0.84.0)
18
18
  Project-URL: Documentation, https://github.com/Experto-AI/quickscale/tree/main/docs
19
19
  Project-URL: Homepage, https://github.com/Experto-AI/quickscale
20
20
  Project-URL: Repository, https://github.com/Experto-AI/quickscale
@@ -24,11 +24,12 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  Command-line interface package for QuickScale.
26
26
 
27
- This package exposes the `quickscale` command used to plan, apply, inspect, and operate generated projects.
27
+ This package exposes the `quickscale` command used to plan, apply, inspect, recover, and operate generated projects.
28
28
 
29
29
  ## Command groups
30
30
 
31
31
  - Project lifecycle: `plan`, `apply`, `status`, `remove`
32
+ - Disaster recovery & promotion: `dr capture`, `dr plan`, `dr execute`, `dr report`
32
33
  - Local development: `up`, `down`, `ps`, `logs`, `shell`, `manage`
33
34
  - Deployment: `deploy`
34
35
  - Module workflows: `update`, `push`
@@ -2,11 +2,12 @@
2
2
 
3
3
  Command-line interface package for QuickScale.
4
4
 
5
- This package exposes the `quickscale` command used to plan, apply, inspect, and operate generated projects.
5
+ This package exposes the `quickscale` command used to plan, apply, inspect, recover, and operate generated projects.
6
6
 
7
7
  ## Command groups
8
8
 
9
9
  - Project lifecycle: `plan`, `apply`, `status`, `remove`
10
+ - Disaster recovery & promotion: `dr capture`, `dr plan`, `dr execute`, `dr report`
10
11
  - Local development: `up`, `down`, `ps`, `logs`, `shell`, `manage`
11
12
  - Deployment: `deploy`
12
13
  - Module workflows: `update`, `push`
@@ -1,8 +1,8 @@
1
1
  [project]
2
2
  name = "quickscale-cli"
3
- version = "0.76.0"
3
+ version = "0.83.0"
4
4
  description = "Command-line interface for QuickScale Django project generator"
5
- requires-python = ">=3.14,<4.0"
5
+ requires-python = ">=3.12,<3.14"
6
6
  readme = "README.md"
7
7
  license = "Apache-2.0"
8
8
  authors = [{name = "Experto AI", email = "victor@experto.ai"}]
@@ -29,9 +29,9 @@ quickscale = "quickscale_cli.main:cli"
29
29
  packages = [{include = "quickscale_cli", from = "src"}]
30
30
 
31
31
  [tool.poetry.dependencies]
32
- python = "^3.14"
32
+ python = ">=3.12,<3.14"
33
33
  click = "^8.3.1"
34
- quickscale-core = "^0.76.0"
34
+ quickscale-core = "^0.83.0"
35
35
 
36
36
  [tool.poetry.group.dev.dependencies]
37
37
  # Dev dependencies centralized in root pyproject.toml
@@ -43,6 +43,7 @@ build-backend = "poetry.core.masonry.api"
43
43
 
44
44
  [tool.pytest.ini_options]
45
45
  testpaths = ["tests"]
46
+ pythonpath = ["src"]
46
47
  python_files = ["test_*.py"]
47
48
  python_classes = ["Test*"]
48
49
  python_functions = ["test_*"]
@@ -7,15 +7,22 @@ stays in sync with other packages when the release is bumped in one place.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import logging
10
11
  from pathlib import Path
11
12
 
12
13
  __author__ = "Experto AI"
13
14
  __email__ = "victor@experto.ai"
14
15
 
16
+ logger = logging.getLogger(__name__)
17
+
15
18
  # Prefer an embedded package-level `_version.py` if it exists (build step).
16
19
  try:
17
20
  from ._version import __version__
18
- except Exception:
21
+ except Exception as exc:
22
+ logger.warning(
23
+ "Failed to import embedded CLI version; falling back to VERSION file or 0.0.0: %s",
24
+ exc,
25
+ )
19
26
  _root = Path(__file__).resolve().parents[3]
20
27
  _version_file = _root / "VERSION"
21
28
  if _version_file.exists():
@@ -1,2 +1,2 @@
1
1
  # Auto-generated by scripts/version_tool.sh
2
- __version__ = "0.76.0"
2
+ __version__ = "0.83.0"
@@ -0,0 +1,177 @@
1
+ """Shared analytics-module configuration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ import re
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ ANALYTICS_PROVIDER_POSTHOG = "posthog"
11
+ ANALYTICS_PROVIDERS = (ANALYTICS_PROVIDER_POSTHOG,)
12
+
13
+ DEFAULT_ANALYTICS_POSTHOG_API_KEY_ENV_VAR = "POSTHOG_API_KEY"
14
+ DEFAULT_ANALYTICS_POSTHOG_HOST_ENV_VAR = "POSTHOG_HOST"
15
+
16
+ ANALYTICS_POSTHOG_DEFAULT_HOST = "https://us.i.posthog.com"
17
+ ANALYTICS_POSTHOG_EU_HOST = "https://eu.i.posthog.com"
18
+
19
+ ANALYTICS_EVENT_PAGEVIEW = "$pageview"
20
+ ANALYTICS_EVENT_FORM_SUBMIT = "form_submit"
21
+ ANALYTICS_EVENT_SOCIAL_LINK_CLICK = "social_link_click"
22
+
23
+ _ANALYTICS_ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
24
+
25
+
26
+ def default_analytics_module_options() -> dict[str, Any]:
27
+ """Return the default planner/apply contract for analytics."""
28
+ return {
29
+ "enabled": True,
30
+ "provider": ANALYTICS_PROVIDER_POSTHOG,
31
+ "posthog_api_key_env_var": DEFAULT_ANALYTICS_POSTHOG_API_KEY_ENV_VAR,
32
+ "posthog_host_env_var": DEFAULT_ANALYTICS_POSTHOG_HOST_ENV_VAR,
33
+ "posthog_host": ANALYTICS_POSTHOG_DEFAULT_HOST,
34
+ "exclude_debug": True,
35
+ "exclude_staff": False,
36
+ "anonymous_by_default": True,
37
+ }
38
+
39
+
40
+ def _normalize_provider(value: Any) -> str:
41
+ return str(value).strip().lower()
42
+
43
+
44
+ def _normalize_posthog_host(value: Any) -> str:
45
+ candidate = str(value).strip()
46
+ if not candidate:
47
+ return ""
48
+ if not candidate.startswith(("http://", "https://")):
49
+ candidate = "https://" + candidate.lstrip("/")
50
+ return candidate.rstrip("/")
51
+
52
+
53
+ def normalize_analytics_module_options(
54
+ options: Mapping[str, Any] | None,
55
+ ) -> dict[str, Any]:
56
+ """Return analytics options with normalized provider and host fields."""
57
+ normalized = dict(options or {})
58
+
59
+ if "provider" in normalized:
60
+ normalized["provider"] = _normalize_provider(normalized["provider"])
61
+
62
+ for option_name in ("posthog_api_key_env_var", "posthog_host_env_var"):
63
+ if option_name in normalized:
64
+ normalized[option_name] = str(normalized[option_name]).strip()
65
+
66
+ if "posthog_host" in normalized:
67
+ normalized["posthog_host"] = _normalize_posthog_host(normalized["posthog_host"])
68
+
69
+ return normalized
70
+
71
+
72
+ def resolve_analytics_module_options(
73
+ options: Mapping[str, Any] | None,
74
+ ) -> dict[str, Any]:
75
+ """Merge analytics options with defaults and normalized overrides."""
76
+ resolved = default_analytics_module_options()
77
+ resolved.update(normalize_analytics_module_options(options))
78
+ resolved["provider"] = _normalize_provider(resolved["provider"])
79
+ resolved["posthog_api_key_env_var"] = str(
80
+ resolved["posthog_api_key_env_var"]
81
+ ).strip()
82
+ resolved["posthog_host_env_var"] = str(resolved["posthog_host_env_var"]).strip()
83
+ resolved["posthog_host"] = _normalize_posthog_host(resolved["posthog_host"])
84
+ return resolved
85
+
86
+
87
+ def validate_analytics_env_var_reference(option_name: str, value: Any) -> str | None:
88
+ """Validate an analytics env-var reference field."""
89
+ candidate = str(value).strip()
90
+ if not candidate:
91
+ return None
92
+
93
+ qualified_option = f"modules.analytics.{option_name}"
94
+ if not _ANALYTICS_ENV_VAR_NAME_PATTERN.fullmatch(candidate):
95
+ return (
96
+ f"{qualified_option} must be an environment variable name matching "
97
+ "^[A-Z][A-Z0-9_]*$"
98
+ )
99
+ return None
100
+
101
+
102
+ def _is_valid_posthog_host(value: str) -> bool:
103
+ candidate = value.strip()
104
+ if not candidate:
105
+ return False
106
+
107
+ parsed = urlsplit(candidate)
108
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
109
+
110
+
111
+ def validate_analytics_module_options(options: Mapping[str, Any] | None) -> list[str]:
112
+ """Return validation issues for analytics module options."""
113
+ resolved = resolve_analytics_module_options(options)
114
+ issues: list[str] = []
115
+
116
+ provider = str(resolved.get("provider", "")).strip().lower()
117
+ if provider not in ANALYTICS_PROVIDERS:
118
+ issues.append(
119
+ "modules.analytics.provider must be one of: "
120
+ + ", ".join(ANALYTICS_PROVIDERS)
121
+ )
122
+
123
+ for option_name in ("posthog_api_key_env_var", "posthog_host_env_var"):
124
+ issue = validate_analytics_env_var_reference(
125
+ option_name,
126
+ resolved.get(option_name, ""),
127
+ )
128
+ if issue:
129
+ issues.append(issue)
130
+
131
+ if not _is_valid_posthog_host(str(resolved.get("posthog_host", ""))):
132
+ issues.append("modules.analytics.posthog_host must be an absolute http(s) URL")
133
+
134
+ for option_name in (
135
+ "enabled",
136
+ "exclude_debug",
137
+ "exclude_staff",
138
+ "anonymous_by_default",
139
+ ):
140
+ if not isinstance(resolved.get(option_name), bool):
141
+ issues.append(f"modules.analytics.{option_name} must be a boolean")
142
+
143
+ return issues
144
+
145
+
146
+ def analytics_production_targeted(options: Mapping[str, Any] | None) -> bool:
147
+ """Return whether analytics is configured for live runtime capture."""
148
+ resolved = resolve_analytics_module_options(options)
149
+ if not bool(resolved.get("enabled", True)):
150
+ return False
151
+
152
+ api_key_env_var = str(resolved.get("posthog_api_key_env_var", "")).strip()
153
+ return not bool(
154
+ validate_analytics_env_var_reference(
155
+ "posthog_api_key_env_var",
156
+ api_key_env_var,
157
+ )
158
+ )
159
+
160
+
161
+ __all__ = [
162
+ "ANALYTICS_EVENT_FORM_SUBMIT",
163
+ "ANALYTICS_EVENT_PAGEVIEW",
164
+ "ANALYTICS_EVENT_SOCIAL_LINK_CLICK",
165
+ "ANALYTICS_POSTHOG_DEFAULT_HOST",
166
+ "ANALYTICS_POSTHOG_EU_HOST",
167
+ "ANALYTICS_PROVIDER_POSTHOG",
168
+ "ANALYTICS_PROVIDERS",
169
+ "DEFAULT_ANALYTICS_POSTHOG_API_KEY_ENV_VAR",
170
+ "DEFAULT_ANALYTICS_POSTHOG_HOST_ENV_VAR",
171
+ "analytics_production_targeted",
172
+ "default_analytics_module_options",
173
+ "normalize_analytics_module_options",
174
+ "resolve_analytics_module_options",
175
+ "validate_analytics_env_var_reference",
176
+ "validate_analytics_module_options",
177
+ ]
@@ -0,0 +1,97 @@
1
+ """Shared auth-module configuration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ AUTH_REGISTRATION_ENABLED_OPTION = "registration_enabled"
9
+ AUTH_EMAIL_VERIFICATION_OPTION = "email_verification"
10
+ AUTH_AUTHENTICATION_METHOD_OPTION = "authentication_method"
11
+ AUTH_SESSION_COOKIE_AGE_OPTION = "session_cookie_age"
12
+
13
+ AUTH_EMAIL_VERIFICATION_VALUES = ("none", "optional", "mandatory")
14
+ AUTH_AUTHENTICATION_METHOD_VALUES = ("email", "username", "both")
15
+ CANONICAL_AUTH_MODULE_OPTION_KEYS = frozenset(
16
+ {
17
+ AUTH_REGISTRATION_ENABLED_OPTION,
18
+ AUTH_EMAIL_VERIFICATION_OPTION,
19
+ AUTH_AUTHENTICATION_METHOD_OPTION,
20
+ AUTH_SESSION_COOKIE_AGE_OPTION,
21
+ }
22
+ )
23
+
24
+ LEGACY_AUTH_ALLOW_REGISTRATION_OPTION = "allow_registration"
25
+ LEGACY_AUTH_SOCIAL_PROVIDERS_OPTION = "social_providers"
26
+
27
+
28
+ def default_auth_module_options() -> dict[str, Any]:
29
+ """Return the default planner/apply contract for auth."""
30
+ return {
31
+ AUTH_REGISTRATION_ENABLED_OPTION: True,
32
+ AUTH_EMAIL_VERIFICATION_OPTION: "none",
33
+ AUTH_AUTHENTICATION_METHOD_OPTION: "email",
34
+ AUTH_SESSION_COOKIE_AGE_OPTION: 1209600,
35
+ }
36
+
37
+
38
+ def format_auth_desired_config_contract() -> str:
39
+ """Return concise remediation text for canonical auth desired config."""
40
+ return "\n".join(
41
+ [
42
+ "Canonical auth keys/value shapes:",
43
+ "modules.auth.registration_enabled: true|false",
44
+ "modules.auth.email_verification: none|optional|mandatory",
45
+ "modules.auth.authentication_method: email|username|both",
46
+ "modules.auth.session_cookie_age: <positive integer seconds> # optional",
47
+ (
48
+ "Remove legacy keys like modules.auth.allow_registration and "
49
+ "modules.auth.social_providers."
50
+ ),
51
+ ]
52
+ )
53
+
54
+
55
+ def normalize_auth_module_options(
56
+ options: Mapping[str, Any] | None,
57
+ ) -> dict[str, Any]:
58
+ """Return auth options with legacy keys normalized or removed."""
59
+ normalized = dict(options or {})
60
+
61
+ if (
62
+ AUTH_REGISTRATION_ENABLED_OPTION not in normalized
63
+ and LEGACY_AUTH_ALLOW_REGISTRATION_OPTION in normalized
64
+ ):
65
+ normalized[AUTH_REGISTRATION_ENABLED_OPTION] = normalized[
66
+ LEGACY_AUTH_ALLOW_REGISTRATION_OPTION
67
+ ]
68
+
69
+ normalized.pop(LEGACY_AUTH_ALLOW_REGISTRATION_OPTION, None)
70
+ normalized.pop(LEGACY_AUTH_SOCIAL_PROVIDERS_OPTION, None)
71
+ return normalized
72
+
73
+
74
+ def resolve_auth_module_options(
75
+ options: Mapping[str, Any] | None,
76
+ ) -> dict[str, Any]:
77
+ """Merge auth options with defaults and normalized overrides."""
78
+ resolved = default_auth_module_options()
79
+ resolved.update(normalize_auth_module_options(options))
80
+ return resolved
81
+
82
+
83
+ __all__ = [
84
+ "AUTH_AUTHENTICATION_METHOD_OPTION",
85
+ "AUTH_AUTHENTICATION_METHOD_VALUES",
86
+ "AUTH_EMAIL_VERIFICATION_OPTION",
87
+ "AUTH_EMAIL_VERIFICATION_VALUES",
88
+ "AUTH_REGISTRATION_ENABLED_OPTION",
89
+ "AUTH_SESSION_COOKIE_AGE_OPTION",
90
+ "CANONICAL_AUTH_MODULE_OPTION_KEYS",
91
+ "LEGACY_AUTH_ALLOW_REGISTRATION_OPTION",
92
+ "LEGACY_AUTH_SOCIAL_PROVIDERS_OPTION",
93
+ "default_auth_module_options",
94
+ "format_auth_desired_config_contract",
95
+ "normalize_auth_module_options",
96
+ "resolve_auth_module_options",
97
+ ]
@@ -0,0 +1,137 @@
1
+ """Shared module configuration helpers.
2
+
3
+ This module centralizes the non-secret configuration contract used by the
4
+ planner, apply flow, and state persistence for module compatibility helpers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ import re
11
+ from typing import Any
12
+
13
+ from quickscale_cli.analytics_contract import normalize_analytics_module_options
14
+ from quickscale_cli.auth_contract import normalize_auth_module_options
15
+ from quickscale_cli.crm_contract import normalize_crm_module_options
16
+ from quickscale_cli.notifications_contract import normalize_notifications_module_options
17
+ from quickscale_cli.social_contract import normalize_social_module_options
18
+
19
+ DEFAULT_BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR = "QUICKSCALE_BACKUPS_REMOTE_ACCESS_KEY_ID"
20
+ DEFAULT_BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR = (
21
+ "QUICKSCALE_BACKUPS_REMOTE_SECRET_ACCESS_KEY"
22
+ )
23
+
24
+ BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR_OPTION = "remote_access_key_id_env_var"
25
+ BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR_OPTION = "remote_secret_access_key_env_var"
26
+
27
+ _LEGACY_BACKUPS_SECRET_OPTIONS = {
28
+ "remote_access_key_id": DEFAULT_BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR,
29
+ "remote_secret_access_key": DEFAULT_BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR,
30
+ }
31
+
32
+ _BACKUPS_ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
33
+ _LIKELY_AWS_ACCESS_KEY_ID_PATTERN = re.compile(r"^(?:AKIA|ASIA)[A-Z0-9]{16}$")
34
+
35
+
36
+ def normalize_backups_module_options(
37
+ options: Mapping[str, Any] | None,
38
+ ) -> dict[str, Any]:
39
+ """Return backups options with legacy raw-secret keys removed.
40
+
41
+ Legacy raw credential values are converted into conventional environment-variable
42
+ references so downstream persistence layers never re-store the secret values.
43
+ """
44
+ normalized = dict(options or {})
45
+
46
+ legacy_access_key_id = str(normalized.pop("remote_access_key_id", "")).strip()
47
+ legacy_secret_access_key = str(
48
+ normalized.pop("remote_secret_access_key", "")
49
+ ).strip()
50
+
51
+ access_key_env_var = str(
52
+ normalized.get(BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR_OPTION, "")
53
+ ).strip()
54
+ secret_access_key_env_var = str(
55
+ normalized.get(BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR_OPTION, "")
56
+ ).strip()
57
+
58
+ if legacy_access_key_id and not access_key_env_var:
59
+ normalized[BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR_OPTION] = (
60
+ DEFAULT_BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR
61
+ )
62
+ if legacy_secret_access_key and not secret_access_key_env_var:
63
+ normalized[BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR_OPTION] = (
64
+ DEFAULT_BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR
65
+ )
66
+
67
+ return normalized
68
+
69
+
70
+ def has_legacy_backups_secret_values(options: Mapping[str, Any] | None) -> bool:
71
+ """Return whether backups options still include legacy raw-secret keys."""
72
+ if not options:
73
+ return False
74
+
75
+ for option_name in _LEGACY_BACKUPS_SECRET_OPTIONS:
76
+ if str(options.get(option_name, "")).strip():
77
+ return True
78
+ return False
79
+
80
+
81
+ def validate_backups_env_var_reference(option_name: str, value: Any) -> str | None:
82
+ """Validate a backups env-var reference field.
83
+
84
+ Returns an actionable error string when the value is not a safe environment
85
+ variable name or appears to be a literal credential value.
86
+ """
87
+ candidate = str(value).strip()
88
+ if not candidate:
89
+ return None
90
+
91
+ qualified_option = f"modules.backups.{option_name}"
92
+ if not _BACKUPS_ENV_VAR_NAME_PATTERN.fullmatch(candidate):
93
+ return (
94
+ f"{qualified_option} must be an environment variable name matching "
95
+ "^[A-Z][A-Z0-9_]*$"
96
+ )
97
+
98
+ if option_name == BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR_OPTION:
99
+ if _LIKELY_AWS_ACCESS_KEY_ID_PATTERN.fullmatch(candidate):
100
+ return (
101
+ f"{qualified_option} must reference an environment variable name, "
102
+ "not a literal AWS access key id"
103
+ )
104
+
105
+ return None
106
+
107
+
108
+ def sanitize_module_options(
109
+ module_name: str,
110
+ options: Mapping[str, Any] | None,
111
+ ) -> dict[str, Any]:
112
+ """Return module options safe for config/state persistence."""
113
+ if module_name == "analytics":
114
+ return normalize_analytics_module_options(options)
115
+ if module_name == "auth":
116
+ return normalize_auth_module_options(options)
117
+ if module_name == "backups":
118
+ return normalize_backups_module_options(options)
119
+ if module_name == "crm":
120
+ return normalize_crm_module_options(options)
121
+ if module_name == "notifications":
122
+ return normalize_notifications_module_options(options)
123
+ if module_name == "social":
124
+ return normalize_social_module_options(dict(options or {}))
125
+ return dict(options or {})
126
+
127
+
128
+ __all__ = [
129
+ "BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR_OPTION",
130
+ "BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR_OPTION",
131
+ "DEFAULT_BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR",
132
+ "DEFAULT_BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR",
133
+ "has_legacy_backups_secret_values",
134
+ "normalize_backups_module_options",
135
+ "sanitize_module_options",
136
+ "validate_backups_env_var_reference",
137
+ ]