quickscale-cli 0.84.0__tar.gz → 0.86.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 (41) hide show
  1. quickscale_cli-0.86.0/PKG-INFO +43 -0
  2. quickscale_cli-0.86.0/README.md +19 -0
  3. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/pyproject.toml +2 -2
  4. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/_version.py +1 -1
  5. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/backups_contract.py +3 -0
  6. quickscale_cli-0.86.0/src/quickscale_cli/billing_contract.py +183 -0
  7. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/apply_command.py +197 -7
  8. quickscale_cli-0.86.0/src/quickscale_cli/commands/implied_module_defaults.py +23 -0
  9. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/module_commands.py +10 -3
  10. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/module_config.py +126 -0
  11. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/module_wiring_specs.py +54 -0
  12. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/plan_command.py +56 -3
  13. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/status_command.py +12 -6
  14. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/module_catalog.py +14 -7
  15. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/schema/config_schema.py +81 -12
  16. quickscale_cli-0.84.0/PKG-INFO +0 -62
  17. quickscale_cli-0.84.0/README.md +0 -38
  18. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/__init__.py +0 -0
  19. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/analytics_contract.py +0 -0
  20. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/auth_contract.py +0 -0
  21. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/beta_migration.py +0 -0
  22. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/__init__.py +0 -0
  23. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/deployment_commands.py +0 -0
  24. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/development_commands.py +0 -0
  25. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/dr_commands.py +0 -0
  26. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/commands/remove_command.py +0 -0
  27. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/crm_contract.py +0 -0
  28. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/main.py +0 -0
  29. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/notifications_contract.py +0 -0
  30. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/schema/__init__.py +0 -0
  31. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/schema/delta.py +0 -0
  32. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/schema/state_schema.py +0 -0
  33. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/social_contract.py +0 -0
  34. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/__init__.py +0 -0
  35. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/dependency_utils.py +0 -0
  36. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/docker_utils.py +0 -0
  37. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/module_dependency_sync.py +0 -0
  38. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/module_wiring_manager.py +0 -0
  39. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/project_identity.py +0 -0
  40. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/project_manager.py +0 -0
  41. {quickscale_cli-0.84.0 → quickscale_cli-0.86.0}/src/quickscale_cli/utils/railway_utils.py +0 -0
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: quickscale-cli
3
+ Version: 0.86.0
4
+ Summary: Command-line interface for QuickScale Django project generator
5
+ License-Expression: Apache-2.0
6
+ Keywords: django,cli,project-generator,saas
7
+ Author: Experto AI
8
+ Author-email: victor@experto.ai
9
+ Requires-Python: >=3.13,<3.15
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Django :: 6.0
17
+ Requires-Dist: click (>=8.3.1,<9.0.0)
18
+ Requires-Dist: quickscale-core (>=0.86.0,<0.87.0)
19
+ Project-URL: Documentation, https://github.com/Experto-AI/quickscale/tree/main/docs
20
+ Project-URL: Homepage, https://github.com/Experto-AI/quickscale
21
+ Project-URL: Repository, https://github.com/Experto-AI/quickscale
22
+ Description-Content-Type: text/markdown
23
+
24
+ # quickscale-cli
25
+
26
+ Command-line interface for the [QuickScale](https://github.com/Experto-AI/quickscale) Django project generator.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install quickscale-cli
32
+ ```
33
+
34
+ Or install the full bundle:
35
+
36
+ ```bash
37
+ pip install quickscale
38
+ ```
39
+
40
+ ## Documentation
41
+
42
+ See the [main repository README](../README.md) and [docs/](../docs/) for full documentation.
43
+
@@ -0,0 +1,19 @@
1
+ # quickscale-cli
2
+
3
+ Command-line interface for the [QuickScale](https://github.com/Experto-AI/quickscale) Django project generator.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install quickscale-cli
9
+ ```
10
+
11
+ Or install the full bundle:
12
+
13
+ ```bash
14
+ pip install quickscale
15
+ ```
16
+
17
+ ## Documentation
18
+
19
+ See the [main repository README](../README.md) and [docs/](../docs/) for full documentation.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quickscale-cli"
3
- version = "0.84.0"
3
+ version = "0.86.0"
4
4
  description = "Command-line interface for QuickScale Django project generator"
5
5
  requires-python = ">=3.13,<3.15"
6
6
  readme = "README.md"
@@ -32,7 +32,7 @@ packages = [{include = "quickscale_cli", from = "src"}]
32
32
  [tool.poetry.dependencies]
33
33
  python = ">=3.13,<3.15"
34
34
  click = "^8.3.1"
35
- quickscale-core = "^0.84.0"
35
+ quickscale-core = "^0.86.0"
36
36
 
37
37
  [tool.poetry.group.dev.dependencies]
38
38
  # Dev dependencies centralized in root pyproject.toml
@@ -1,2 +1,2 @@
1
1
  # Auto-generated by scripts/version_tool.sh
2
- __version__ = "0.84.0"
2
+ __version__ = "0.86.0"
@@ -10,6 +10,7 @@ from collections.abc import Mapping
10
10
  import re
11
11
  from typing import Any
12
12
 
13
+ from quickscale_cli.billing_contract import normalize_billing_module_options
13
14
  from quickscale_cli.analytics_contract import normalize_analytics_module_options
14
15
  from quickscale_cli.auth_contract import normalize_auth_module_options
15
16
  from quickscale_cli.crm_contract import normalize_crm_module_options
@@ -116,6 +117,8 @@ def sanitize_module_options(
116
117
  return normalize_auth_module_options(options)
117
118
  if module_name == "backups":
118
119
  return normalize_backups_module_options(options)
120
+ if module_name == "billing":
121
+ return normalize_billing_module_options(options)
119
122
  if module_name == "crm":
120
123
  return normalize_crm_module_options(options)
121
124
  if module_name == "notifications":
@@ -0,0 +1,183 @@
1
+ """Shared billing-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
+
9
+ DEFAULT_BILLING_PUBLISHABLE_KEY_ENV_VAR = "STRIPE_PUBLISHABLE_KEY"
10
+ DEFAULT_BILLING_SECRET_KEY_ENV_VAR = "STRIPE_SECRET_KEY"
11
+ DEFAULT_BILLING_WEBHOOK_SECRET_ENV_VAR = "QUICKSCALE_BILLING_WEBHOOK_SECRET"
12
+ DEFAULT_BILLING_CURRENCY = "usd"
13
+
14
+ BILLING_ENV_VAR_OPTION_NAMES = (
15
+ "publishable_key_env_var",
16
+ "secret_key_env_var",
17
+ "webhook_secret_env_var",
18
+ )
19
+ BILLING_MODULE_OPTION_KEYS = frozenset(
20
+ {
21
+ "enabled",
22
+ *BILLING_ENV_VAR_OPTION_NAMES,
23
+ "billing_currency",
24
+ }
25
+ )
26
+ BILLING_SUPPORTED_CURRENCIES = (
27
+ "aud",
28
+ "brl",
29
+ "cad",
30
+ "chf",
31
+ "czk",
32
+ "dkk",
33
+ "eur",
34
+ "gbp",
35
+ "hkd",
36
+ "huf",
37
+ "inr",
38
+ "jpy",
39
+ "mxn",
40
+ "myr",
41
+ "nok",
42
+ "nzd",
43
+ "php",
44
+ "pln",
45
+ "ron",
46
+ "sek",
47
+ "sgd",
48
+ "thb",
49
+ "try",
50
+ "usd",
51
+ "zar",
52
+ )
53
+
54
+ _BILLING_ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
55
+
56
+
57
+ def default_billing_module_options() -> dict[str, Any]:
58
+ """Return the default planner/apply contract for billing."""
59
+ return {
60
+ "enabled": True,
61
+ "publishable_key_env_var": DEFAULT_BILLING_PUBLISHABLE_KEY_ENV_VAR,
62
+ "secret_key_env_var": DEFAULT_BILLING_SECRET_KEY_ENV_VAR,
63
+ "webhook_secret_env_var": DEFAULT_BILLING_WEBHOOK_SECRET_ENV_VAR,
64
+ "billing_currency": DEFAULT_BILLING_CURRENCY,
65
+ }
66
+
67
+
68
+ def normalize_billing_module_options(
69
+ options: Mapping[str, Any] | None,
70
+ ) -> dict[str, Any]:
71
+ """Return billing options with normalized env-var and currency values."""
72
+ normalized = dict(options or {})
73
+
74
+ for option_name in BILLING_ENV_VAR_OPTION_NAMES:
75
+ if option_name in normalized:
76
+ normalized[option_name] = str(normalized[option_name]).strip()
77
+
78
+ if "billing_currency" in normalized:
79
+ normalized["billing_currency"] = (
80
+ str(normalized["billing_currency"]).strip().lower()
81
+ )
82
+
83
+ return normalized
84
+
85
+
86
+ def resolve_billing_module_options(
87
+ options: Mapping[str, Any] | None,
88
+ ) -> dict[str, Any]:
89
+ """Merge billing options with defaults and normalized overrides."""
90
+ resolved = default_billing_module_options()
91
+ resolved.update(normalize_billing_module_options(options))
92
+ for option_name in BILLING_ENV_VAR_OPTION_NAMES:
93
+ resolved[option_name] = str(resolved[option_name]).strip()
94
+ resolved["billing_currency"] = str(resolved["billing_currency"]).strip().lower()
95
+ return resolved
96
+
97
+
98
+ def validate_billing_env_var_reference(option_name: str, value: Any) -> str | None:
99
+ """Validate a billing env-var reference field."""
100
+ candidate = str(value).strip()
101
+ if not candidate:
102
+ return None
103
+
104
+ qualified_option = f"modules.billing.{option_name}"
105
+ if not _BILLING_ENV_VAR_NAME_PATTERN.fullmatch(candidate):
106
+ return (
107
+ f"{qualified_option} must be an environment variable name matching "
108
+ "^[A-Z][A-Z0-9_]*$"
109
+ )
110
+ return None
111
+
112
+
113
+ def validate_billing_currency(value: Any) -> str | None:
114
+ """Validate the configured billing currency code."""
115
+ candidate = str(value).strip().lower()
116
+ if not candidate:
117
+ return "modules.billing.billing_currency cannot be blank"
118
+
119
+ if candidate not in BILLING_SUPPORTED_CURRENCIES:
120
+ return (
121
+ "modules.billing.billing_currency must be one of the supported "
122
+ "QuickScale billing currency codes: "
123
+ + ", ".join(BILLING_SUPPORTED_CURRENCIES)
124
+ )
125
+ return None
126
+
127
+
128
+ def validate_billing_module_options(options: Mapping[str, Any] | None) -> list[str]:
129
+ """Return validation issues for billing module options."""
130
+ resolved = resolve_billing_module_options(options)
131
+ issues: list[str] = []
132
+
133
+ if not isinstance(resolved.get("enabled", True), bool):
134
+ issues.append("modules.billing.enabled must be a boolean")
135
+
136
+ for option_name in BILLING_ENV_VAR_OPTION_NAMES:
137
+ issue = validate_billing_env_var_reference(
138
+ option_name,
139
+ resolved.get(option_name, ""),
140
+ )
141
+ if issue:
142
+ issues.append(issue)
143
+
144
+ currency_issue = validate_billing_currency(resolved.get("billing_currency", ""))
145
+ if currency_issue:
146
+ issues.append(currency_issue)
147
+
148
+ return issues
149
+
150
+
151
+ def billing_production_targeted(options: Mapping[str, Any] | None) -> bool:
152
+ """Return whether billing is configured for live runtime ownership."""
153
+ resolved = resolve_billing_module_options(options)
154
+ if not bool(resolved.get("enabled", True)):
155
+ return False
156
+
157
+ if validate_billing_currency(resolved.get("billing_currency", "")):
158
+ return False
159
+
160
+ for option_name in BILLING_ENV_VAR_OPTION_NAMES:
161
+ candidate = str(resolved.get(option_name, "")).strip()
162
+ if not candidate or validate_billing_env_var_reference(option_name, candidate):
163
+ return False
164
+
165
+ return True
166
+
167
+
168
+ __all__ = [
169
+ "BILLING_ENV_VAR_OPTION_NAMES",
170
+ "BILLING_MODULE_OPTION_KEYS",
171
+ "BILLING_SUPPORTED_CURRENCIES",
172
+ "DEFAULT_BILLING_CURRENCY",
173
+ "DEFAULT_BILLING_PUBLISHABLE_KEY_ENV_VAR",
174
+ "DEFAULT_BILLING_SECRET_KEY_ENV_VAR",
175
+ "DEFAULT_BILLING_WEBHOOK_SECRET_ENV_VAR",
176
+ "billing_production_targeted",
177
+ "default_billing_module_options",
178
+ "normalize_billing_module_options",
179
+ "resolve_billing_module_options",
180
+ "validate_billing_currency",
181
+ "validate_billing_env_var_reference",
182
+ "validate_billing_module_options",
183
+ ]
@@ -21,6 +21,14 @@ from quickscale_cli.analytics_contract import (
21
21
  resolve_analytics_module_options,
22
22
  validate_analytics_module_options,
23
23
  )
24
+ from quickscale_cli.billing_contract import (
25
+ DEFAULT_BILLING_CURRENCY,
26
+ DEFAULT_BILLING_PUBLISHABLE_KEY_ENV_VAR,
27
+ DEFAULT_BILLING_SECRET_KEY_ENV_VAR,
28
+ DEFAULT_BILLING_WEBHOOK_SECRET_ENV_VAR,
29
+ resolve_billing_module_options,
30
+ validate_billing_module_options,
31
+ )
24
32
  from quickscale_cli.backups_contract import (
25
33
  BACKUPS_REMOTE_ACCESS_KEY_ID_ENV_VAR_OPTION,
26
34
  BACKUPS_REMOTE_SECRET_ACCESS_KEY_ENV_VAR_OPTION,
@@ -39,6 +47,9 @@ from quickscale_cli.notifications_contract import (
39
47
  resolve_notifications_module_options,
40
48
  validate_notifications_module_options,
41
49
  )
50
+ from quickscale_cli.commands.implied_module_defaults import (
51
+ get_implied_module_default_configs,
52
+ )
42
53
  from quickscale_cli.module_catalog import (
43
54
  find_not_ready_modules,
44
55
  get_module_readiness_reason,
@@ -63,6 +74,7 @@ from quickscale_cli.utils.module_dependency_sync import (
63
74
  from quickscale_cli.utils.module_wiring_manager import regenerate_managed_wiring
64
75
  from quickscale_cli.schema.config_schema import (
65
76
  ConfigValidationError,
77
+ ModuleConfig,
66
78
  QuickScaleConfig,
67
79
  generate_yaml,
68
80
  validate_config,
@@ -470,13 +482,13 @@ def _start_docker(
470
482
 
471
483
 
472
484
  def _abort_for_not_ready_modules(module_names: list[str], *, source: str) -> None:
473
- """Abort apply when placeholder modules appear in config or applied state."""
485
+ """Abort apply when non-public-ready modules appear in config or applied state."""
474
486
  not_ready = find_not_ready_modules(module_names)
475
487
  if not not_ready:
476
488
  return
477
489
 
478
490
  click.secho(
479
- f"\n❌ {source} references placeholder modules that are not ready:",
491
+ f"\n❌ {source} references modules that are not public-ready:",
480
492
  fg="red",
481
493
  err=True,
482
494
  bold=True,
@@ -680,6 +692,21 @@ def _sanitize_loaded_module_configs(qs_config: QuickScaleConfig) -> list[str]:
680
692
  return sanitized_modules
681
693
 
682
694
 
695
+ def _materialize_implied_module_configs(qs_config: QuickScaleConfig) -> list[str]:
696
+ """Add explicit module config blocks required by selected modules."""
697
+ implied_configs = get_implied_module_default_configs(qs_config.modules.keys())
698
+ if not implied_configs:
699
+ return []
700
+
701
+ for module_name, options in implied_configs.items():
702
+ qs_config.modules[module_name] = ModuleConfig(
703
+ name=module_name,
704
+ options=sanitize_module_options(module_name, options),
705
+ )
706
+
707
+ return list(implied_configs.keys())
708
+
709
+
683
710
  def _load_and_validate_config(config_path: Path) -> QuickScaleConfig:
684
711
  """Load and validate configuration from file."""
685
712
  if not config_path.exists():
@@ -698,16 +725,24 @@ def _load_and_validate_config(config_path: Path) -> QuickScaleConfig:
698
725
  qs_config = validate_config(yaml_content)
699
726
  original_data = yaml.safe_load(yaml_content) or {}
700
727
  original_modules = original_data.get("modules") or {}
701
- _sanitize_loaded_module_configs(qs_config)
728
+ sanitized_modules = _sanitize_loaded_module_configs(qs_config)
729
+ implied_modules = _materialize_implied_module_configs(qs_config)
702
730
  normalized_yaml = generate_yaml(qs_config)
703
731
  normalized_data = yaml.safe_load(normalized_yaml) or {}
704
732
  normalized_modules = normalized_data.get("modules") or {}
705
733
  if normalized_modules != original_modules:
706
734
  config_path.write_text(normalized_yaml)
707
- click.secho(
708
- "✅ Sanitized legacy module config keys in quickscale.yml",
709
- fg="green",
710
- )
735
+ if sanitized_modules:
736
+ click.secho(
737
+ "✅ Sanitized legacy module config keys in quickscale.yml",
738
+ fg="green",
739
+ )
740
+ if implied_modules:
741
+ click.secho(
742
+ "✅ Added implied module config to quickscale.yml: "
743
+ + ", ".join(implied_modules),
744
+ fg="green",
745
+ )
711
746
  _validate_module_prerequisites(qs_config)
712
747
  return qs_config
713
748
  except ConfigValidationError as e:
@@ -720,6 +755,64 @@ def _load_and_validate_config(config_path: Path) -> QuickScaleConfig:
720
755
 
721
756
  def _validate_module_prerequisites(qs_config: QuickScaleConfig) -> None:
722
757
  """Validate actionable module-specific prerequisites before apply proceeds."""
758
+ if "orgs" in qs_config.modules and "auth" not in qs_config.modules:
759
+ click.secho(
760
+ "\n❌ Organizations requires the auth module before apply can continue:",
761
+ fg="red",
762
+ err=True,
763
+ )
764
+ click.echo(
765
+ " • Add 'auth' under modules in quickscale.yml before applying orgs.",
766
+ err=True,
767
+ )
768
+ click.echo(
769
+ "\n💡 Organizations relies on the QuickScale auth login flow and "
770
+ "django-allauth account adapter hooks. Re-run 'quickscale plan "
771
+ "--reconfigure --configure-modules' or edit quickscale.yml to add "
772
+ "auth, then re-run 'quickscale apply'.",
773
+ err=True,
774
+ )
775
+ raise click.Abort()
776
+
777
+ billing_config = qs_config.modules.get("billing")
778
+ if billing_config is not None:
779
+ billing_issues = validate_billing_module_options(billing_config.options or {})
780
+ if billing_issues:
781
+ click.secho(
782
+ "\n❌ Billing module configuration is incomplete for apply:",
783
+ fg="red",
784
+ err=True,
785
+ )
786
+ for issue in billing_issues:
787
+ click.echo(f" • {issue}", err=True)
788
+ click.echo(
789
+ "\n💡 Re-run 'quickscale plan --reconfigure --configure-modules' or "
790
+ "edit quickscale.yml to correct the billing option values. "
791
+ "Billing apply requires valid env-var references and a supported "
792
+ "currency code.",
793
+ err=True,
794
+ )
795
+ raise click.Abort()
796
+
797
+ if "auth" not in qs_config.modules:
798
+ click.secho(
799
+ "\n❌ Billing requires the auth module before apply can continue:",
800
+ fg="red",
801
+ err=True,
802
+ )
803
+ click.echo(
804
+ " • Add 'auth' under modules in quickscale.yml before applying billing.",
805
+ err=True,
806
+ )
807
+ click.echo(
808
+ "\n💡 Billing relies on the QuickScale auth login flow and "
809
+ "AUTH_USER_MODEL. Re-run 'quickscale plan --reconfigure "
810
+ "--configure-modules' or edit quickscale.yml to add auth, then "
811
+ "re-run 'quickscale apply'.",
812
+ err=True,
813
+ )
814
+ raise click.Abort()
815
+
723
816
  _abort_for_not_ready_modules(
724
817
  list(qs_config.modules.keys()), source="quickscale.yml"
725
818
  )
@@ -1004,6 +1097,95 @@ def _sync_analytics_env_example(
1004
1097
  return True
1005
1098
 
1006
1099
 
1100
+ def _render_billing_env_example_block(
1101
+ options: Mapping[str, Any] | None,
1102
+ ) -> str:
1103
+ """Render the managed billing section for `.env.example`."""
1104
+ resolved = resolve_billing_module_options(options)
1105
+ publishable_key_env_var = str(
1106
+ resolved.get(
1107
+ "publishable_key_env_var",
1108
+ DEFAULT_BILLING_PUBLISHABLE_KEY_ENV_VAR,
1109
+ )
1110
+ or DEFAULT_BILLING_PUBLISHABLE_KEY_ENV_VAR
1111
+ ).strip()
1112
+ secret_key_env_var = str(
1113
+ resolved.get(
1114
+ "secret_key_env_var",
1115
+ DEFAULT_BILLING_SECRET_KEY_ENV_VAR,
1116
+ )
1117
+ or DEFAULT_BILLING_SECRET_KEY_ENV_VAR
1118
+ ).strip()
1119
+ webhook_secret_env_var = str(
1120
+ resolved.get(
1121
+ "webhook_secret_env_var",
1122
+ DEFAULT_BILLING_WEBHOOK_SECRET_ENV_VAR,
1123
+ )
1124
+ or DEFAULT_BILLING_WEBHOOK_SECRET_ENV_VAR
1125
+ ).strip()
1126
+ billing_currency = str(
1127
+ resolved.get("billing_currency", DEFAULT_BILLING_CURRENCY)
1128
+ or DEFAULT_BILLING_CURRENCY
1129
+ ).strip()
1130
+
1131
+ return "\n".join(
1132
+ [
1133
+ "# QuickScale Billing (managed)",
1134
+ "# Runtime Stripe variables for the backend billing module.",
1135
+ f"# Billing currency from quickscale.yml: {billing_currency}",
1136
+ f"{publishable_key_env_var}=",
1137
+ f"{secret_key_env_var}=",
1138
+ f"{webhook_secret_env_var}=",
1139
+ "# End QuickScale Billing",
1140
+ ]
1141
+ )
1142
+
1143
+
1144
+ def _sync_billing_env_example(
1145
+ output_path: Path,
1146
+ qs_config: QuickScaleConfig,
1147
+ ) -> bool:
1148
+ """Keep `.env.example` aligned with the billing env-var names."""
1149
+ billing_config = qs_config.modules.get("billing")
1150
+ env_example_path = output_path / ".env.example"
1151
+ if billing_config is None or not env_example_path.exists():
1152
+ return True
1153
+
1154
+ start_marker = "# QuickScale Billing (managed)"
1155
+ end_marker = "# End QuickScale Billing"
1156
+ rendered_block = _render_billing_env_example_block(billing_config.options or {})
1157
+
1158
+ try:
1159
+ content = env_example_path.read_text()
1160
+ except OSError as e:
1161
+ click.secho(
1162
+ f"⚠️ Failed to read .env.example for billing wiring: {e}",
1163
+ fg="yellow",
1164
+ )
1165
+ return False
1166
+
1167
+ if start_marker in content and end_marker in content:
1168
+ before, remainder = content.split(start_marker, maxsplit=1)
1169
+ _, after = remainder.split(end_marker, maxsplit=1)
1170
+ replacement = rendered_block + after
1171
+ updated_content = before + replacement
1172
+ else:
1173
+ suffix = "" if content.endswith("\n") else "\n"
1174
+ updated_content = content + suffix + "\n" + rendered_block + "\n"
1175
+
1176
+ try:
1177
+ env_example_path.write_text(updated_content)
1178
+ except OSError as e:
1179
+ click.secho(
1180
+ f"⚠️ Failed to update .env.example for billing wiring: {e}",
1181
+ fg="yellow",
1182
+ )
1183
+ return False
1184
+
1185
+ click.secho("✅ Updated .env.example with billing env vars", fg="green")
1186
+ return True
1187
+
1188
+
1007
1189
  def _normalize_backups_gitignore_entry(local_directory: str) -> str | None:
1008
1190
  """Return a safe repo-relative ignore entry for backup artifacts."""
1009
1191
  if any(
@@ -2179,6 +2361,14 @@ def _execute_apply_steps(
2179
2361
  reason="Unable to update .env.example with the configured analytics env-var names.",
2180
2362
  )
2181
2363
 
2364
+ if not _sync_billing_env_example(ctx.output_path, ctx.qs_config):
2365
+ _abort_after_post_embed_failure(
2366
+ ctx,
2367
+ embedded_modules,
2368
+ failed_step="billing env example sync",
2369
+ reason="Unable to update .env.example with the configured billing env-var names.",
2370
+ )
2371
+
2182
2372
  if not _sync_project_module_dependencies_for_apply(
2183
2373
  ctx.output_path,
2184
2374
  ctx.qs_config,
@@ -0,0 +1,23 @@
1
+ """Helpers for materializing implicit module configs in CLI flows."""
2
+
3
+ from collections.abc import Collection
4
+ from typing import Any
5
+
6
+ from quickscale_cli.commands.module_config import get_default_notifications_config
7
+
8
+
9
+ def get_implied_module_default_configs(
10
+ module_names: Collection[str],
11
+ ) -> dict[str, dict[str, Any]]:
12
+ """Return module config blocks that should be made explicit automatically."""
13
+ names = set(module_names)
14
+ implied_configs: dict[str, dict[str, Any]] = {}
15
+
16
+ if "billing" in names and "orgs" not in names:
17
+ implied_configs["orgs"] = {}
18
+ names.add("orgs")
19
+
20
+ if "orgs" in names and "notifications" not in names:
21
+ implied_configs["notifications"] = get_default_notifications_config()
22
+
23
+ return implied_configs
@@ -208,8 +208,12 @@ def _sync_state_module_version(project_path: Path, module: str, version: str) ->
208
208
  state_manager.save(state)
209
209
 
210
210
 
211
- def _validate_module_readiness(module: str) -> bool:
212
- """Reject placeholder modules from embed/apply flows."""
211
+ def _validate_module_readiness(
212
+ module: str,
213
+ *,
214
+ execution_mode: ModuleExecutionMode = STANDALONE_MODULE_EXECUTION_MODE,
215
+ ) -> bool:
216
+ """Reject non-public modules from standalone embed/update flows."""
213
217
  readiness_reason = get_module_readiness_reason(module)
214
218
  if readiness_reason is None:
215
219
  return True
@@ -416,7 +420,10 @@ def embed_module(
416
420
  if not _validate_module_not_exists(project_path, module):
417
421
  return False
418
422
 
419
- if not _validate_module_readiness(module):
423
+ if not _validate_module_readiness(
424
+ module,
425
+ execution_mode=execution_mode,
426
+ ):
420
427
  return False
421
428
 
422
429
  branch = f"splits/{module}-module"