quickscale-cli 0.72.0__tar.gz → 0.73.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 (24) hide show
  1. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/PKG-INFO +9 -9
  2. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/pyproject.toml +7 -7
  3. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/_version.py +1 -1
  4. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/apply_command.py +4 -0
  5. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/deployment_commands.py +3 -5
  6. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/module_commands.py +2 -2
  7. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/module_config.py +291 -8
  8. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/plan_command.py +6 -7
  9. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/remove_command.py +9 -7
  10. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/schema/config_schema.py +1 -1
  11. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/utils/railway_utils.py +4 -21
  12. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/README.md +0 -0
  13. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/__init__.py +0 -0
  14. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/__init__.py +0 -0
  15. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/development_commands.py +0 -0
  16. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/commands/status_command.py +0 -0
  17. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/main.py +0 -0
  18. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/schema/__init__.py +0 -0
  19. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/schema/delta.py +0 -0
  20. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/schema/state_schema.py +0 -0
  21. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/utils/__init__.py +0 -0
  22. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/utils/dependency_utils.py +0 -0
  23. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/utils/docker_utils.py +0 -0
  24. {quickscale_cli-0.72.0 → quickscale_cli-0.73.0}/src/quickscale_cli/utils/project_manager.py +0 -0
@@ -1,25 +1,25 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: quickscale-cli
3
- Version: 0.72.0
3
+ Version: 0.73.0
4
4
  Summary: Command-line interface for QuickScale Django project generator
5
- Home-page: https://github.com/Experto-AI/quickscale
6
5
  License: Apache-2.0
7
6
  Keywords: django,cli,project-generator,saas
8
7
  Author: Experto AI
9
8
  Author-email: victor@experto.ai
10
- Requires-Python: >=3.10,<4.0
9
+ Requires-Python: >=3.12,<4.0
11
10
  Classifier: Development Status :: 3 - Alpha
12
11
  Classifier: Framework :: Django
13
- Classifier: Framework :: Django :: 5.0
12
+ Classifier: Framework :: Django :: 6.0
14
13
  Classifier: Intended Audience :: Developers
15
14
  Classifier: License :: OSI Approved :: Apache Software License
16
15
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.10
18
- Classifier: Programming Language :: Python :: 3.11
19
16
  Classifier: Programming Language :: Python :: 3.12
20
- Requires-Dist: click (>=8.3.0,<9.0.0)
21
- Requires-Dist: quickscale-core @ file:///home/victor/Code/quickscale/quickscale_core
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Dist: click (>=8.3.1,<9.0.0)
20
+ Requires-Dist: quickscale-core (>=0.73.0,<0.74.0)
22
21
  Project-URL: Documentation, https://github.com/Experto-AI/quickscale/tree/main/docs
22
+ Project-URL: Homepage, https://github.com/Experto-AI/quickscale
23
23
  Project-URL: Repository, https://github.com/Experto-AI/quickscale
24
24
  Description-Content-Type: text/markdown
25
25
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "quickscale-cli"
3
- version = "0.72.0"
3
+ version = "0.73.0"
4
4
  description = "Command-line interface for QuickScale Django project generator"
5
5
  authors = ["Experto AI <victor@experto.ai>"]
6
6
  license = "Apache-2.0"
@@ -14,18 +14,18 @@ classifiers = [
14
14
  "Intended Audience :: Developers",
15
15
  "License :: OSI Approved :: Apache Software License",
16
16
  "Programming Language :: Python :: 3",
17
- "Programming Language :: Python :: 3.10",
18
- "Programming Language :: Python :: 3.11",
19
17
  "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
20
  "Framework :: Django",
21
- "Framework :: Django :: 5.0",
21
+ "Framework :: Django :: 6.0",
22
22
  ]
23
23
  packages = [{include = "quickscale_cli", from = "src"}]
24
24
 
25
25
  [tool.poetry.dependencies]
26
- python = "^3.10"
27
- click = "^8.3.0"
28
- quickscale-core = {path = "../quickscale_core"}
26
+ python = "^3.12"
27
+ click = "^8.3.1"
28
+ quickscale-core = "^0.73.0"
29
29
 
30
30
  [tool.poetry.group.dev.dependencies]
31
31
  # Dev dependencies centralized in root pyproject.toml
@@ -1,2 +1,2 @@
1
1
  # Auto-generated by scripts/version_tool.sh
2
- __version__ = "0.72.0"
2
+ __version__ = "0.73.0"
@@ -23,6 +23,7 @@ from quickscale_cli.schema.state_schema import (
23
23
  QuickScaleState,
24
24
  StateManager,
25
25
  )
26
+ from quickscale_core.utils.git_utils import is_working_directory_clean
26
27
  from quickscale_core.generator import ProjectGenerator
27
28
  from quickscale_core.manifest import ModuleManifest
28
29
  from quickscale_core.manifest.loader import get_manifest_for_module
@@ -516,6 +517,9 @@ def _embed_modules_step(
516
517
  f"⚠️ Module embedding failed for {module_name}, continuing...",
517
518
  fg="yellow",
518
519
  )
520
+ # Commit any partial changes to allow subsequent modules to be embedded
521
+ if not is_working_directory_clean(output_path):
522
+ _git_commit(output_path, f"Partial module: {module_name} (incomplete)")
519
523
  else:
520
524
  embedded_modules.append(module_name)
521
525
  _git_commit(output_path, f"Add module: {module_name}")
@@ -95,21 +95,19 @@ def _check_poetry_lock_step() -> None:
95
95
  click.echo(
96
96
  "💡 The lock file needs to be regenerated to match pyproject.toml"
97
97
  )
98
- if click.confirm("Run 'poetry lock --no-update' to fix?", default=True):
98
+ if click.confirm("Run 'poetry lock' to fix?", default=True):
99
99
  click.echo("🔄 Updating poetry.lock...")
100
100
  success, fix_message = fix_poetry_lock()
101
101
  if success:
102
102
  click.secho(f"✅ {fix_message}", fg="green")
103
103
  else:
104
104
  click.secho(f"❌ Error: {fix_message}", fg="red", err=True)
105
- click.echo(
106
- "💡 Try running manually: poetry lock --no-update", err=True
107
- )
105
+ click.echo("💡 Try running manually: poetry lock", err=True)
108
106
  if not click.confirm("Continue anyway?", default=False):
109
107
  click.echo("Deployment cancelled")
110
108
  sys.exit(0)
111
109
  else:
112
- click.echo("💡 Run 'poetry lock --no-update' before deploying")
110
+ click.echo("💡 Run 'poetry lock' before deploying")
113
111
  if not click.confirm("Continue without fixing?", default=False):
114
112
  click.echo("Deployment cancelled")
115
113
  sys.exit(0)
@@ -23,7 +23,7 @@ from .module_config import (
23
23
  )
24
24
 
25
25
  # Available modules
26
- AVAILABLE_MODULES = ["auth", "billing", "teams", "blog", "listings"]
26
+ AVAILABLE_MODULES = ["auth", "billing", "teams", "blog", "listings", "crm"]
27
27
 
28
28
 
29
29
  def _validate_git_environment() -> bool:
@@ -171,7 +171,7 @@ def _perform_module_embed(
171
171
  applier(project_path, config)
172
172
 
173
173
  # Install dependencies for modules that need it
174
- if module in ["auth", "blog", "listings"]:
174
+ if module in ["auth", "blog", "listings", "crm"]:
175
175
  if not _install_module_dependencies(project_path, module):
176
176
  return False
177
177
 
@@ -12,6 +12,35 @@ from typing import Any
12
12
  import click
13
13
 
14
14
 
15
+ def _is_app_in_installed_apps(settings_content: str, app_name: str) -> bool:
16
+ """Check if an app is already in INSTALLED_APPS.
17
+
18
+ Args:
19
+ settings_content: The content of settings.py
20
+ app_name: The app name to check for (e.g., 'django_filters')
21
+
22
+ Returns:
23
+ True if the app is already in INSTALLED_APPS, False otherwise
24
+ """
25
+ # Check for app in INSTALLED_APPS list or INSTALLED_APPS +=
26
+ # Match patterns like: "app_name", 'app_name' in lists
27
+ pattern = rf'["\']({re.escape(app_name)})["\']'
28
+ return bool(re.search(pattern, settings_content))
29
+
30
+
31
+ def _filter_new_apps(settings_content: str, apps: list[str]) -> list[str]:
32
+ """Filter out apps that are already in INSTALLED_APPS.
33
+
34
+ Args:
35
+ settings_content: The content of settings.py
36
+ apps: List of app names to filter
37
+
38
+ Returns:
39
+ List of apps that are NOT already in settings.py
40
+ """
41
+ return [app for app in apps if not _is_app_in_installed_apps(settings_content, app)]
42
+
43
+
15
44
  def has_migrations_been_run() -> bool:
16
45
  """Check if Django migrations have been run in the current project"""
17
46
  # Check for SQLite database file
@@ -560,22 +589,33 @@ def apply_listings_configuration(project_path: Path, config: dict[str, Any]) ->
560
589
  if pyproject_path.exists():
561
590
  _add_django_filter_dependency(project_path, pyproject_path)
562
591
 
563
- # Add required apps to INSTALLED_APPS
564
- installed_apps_addition = """
592
+ # Determine which apps need to be added (avoid duplicates)
593
+ required_apps = ["django_filters", "quickscale_modules_listings"]
594
+ new_apps = _filter_new_apps(settings_content, required_apps)
595
+
596
+ if not new_apps:
597
+ click.echo("ℹ️ All required apps already in INSTALLED_APPS")
598
+ else:
599
+ # Build the INSTALLED_APPS addition with only new apps
600
+ apps_list = ",\n ".join([f'"{app}"' for app in new_apps])
601
+ installed_apps_addition = f"""
565
602
  # QuickScale Listings Module - Added by quickscale embed
566
603
  INSTALLED_APPS += [
567
- "django_filters", # Filtering support
568
- "quickscale_modules_listings", # Listings module
604
+ {apps_list},
569
605
  ]
606
+ """
607
+ # Append to settings.py
608
+ with open(settings_path, "a") as f:
609
+ f.write("\n" + installed_apps_addition)
570
610
 
611
+ # Add settings (always add these)
612
+ settings_addition = f"""
571
613
  # Listings Module Settings
614
+ LISTINGS_PER_PAGE = {config["listings_per_page"]}
572
615
  """
573
616
 
574
- installed_apps_addition += f"LISTINGS_PER_PAGE = {config['listings_per_page']}\n"
575
-
576
- # Append to settings.py
577
617
  with open(settings_path, "a") as f:
578
- f.write("\n" + installed_apps_addition)
618
+ f.write(settings_addition)
579
619
 
580
620
  click.secho(" ✅ Updated settings.py with listings configuration", fg="green")
581
621
 
@@ -602,6 +642,248 @@ INSTALLED_APPS += [
602
642
  click.echo(f" • Listings per page: {config['listings_per_page']}")
603
643
 
604
644
 
645
+ # ============================================================================
646
+ # CRM MODULE CONFIGURATION
647
+ # ============================================================================
648
+
649
+
650
+ def get_default_crm_config() -> dict[str, Any]:
651
+ """Get default configuration for CRM module (non-interactive mode)"""
652
+ return {
653
+ "enable_api": True,
654
+ "deals_per_page": 25,
655
+ "contacts_per_page": 50,
656
+ }
657
+
658
+
659
+ def configure_crm_module(non_interactive: bool = False) -> dict[str, Any]:
660
+ """Interactive configuration for CRM module"""
661
+ if non_interactive:
662
+ click.echo("\n⚙️ Using default CRM module configuration...")
663
+ config = get_default_crm_config()
664
+ click.echo(" • API: Enabled")
665
+ click.echo(f" • Deals per page: {config['deals_per_page']}")
666
+ click.echo(f" • Contacts per page: {config['contacts_per_page']}")
667
+ return config
668
+
669
+ click.echo("\n⚙️ Configuring CRM module...")
670
+ click.echo(
671
+ "The CRM module provides contact management, companies, and deal pipeline.\n"
672
+ )
673
+
674
+ config = {
675
+ "enable_api": click.confirm("Enable REST API endpoints?", default=True),
676
+ "deals_per_page": click.prompt(
677
+ "Deals per page",
678
+ type=int,
679
+ default=25,
680
+ ),
681
+ "contacts_per_page": click.prompt(
682
+ "Contacts per page",
683
+ type=int,
684
+ default=50,
685
+ ),
686
+ }
687
+
688
+ return config
689
+
690
+
691
+ def _add_drf_and_filter_dependencies(project_path: Path, pyproject_path: Path) -> None:
692
+ """Add djangorestframework and django-filter dependencies to project's pyproject.toml."""
693
+ with open(pyproject_path) as f:
694
+ pyproject_content = f.read()
695
+
696
+ # Read versions from the embedded CRM module
697
+ crm_pyproject_path = project_path / "modules" / "crm" / "pyproject.toml"
698
+
699
+ if not crm_pyproject_path.exists():
700
+ click.secho(
701
+ "❌ Error: CRM module pyproject.toml not found. "
702
+ "Cannot determine dependency version requirements.",
703
+ fg="red",
704
+ err=True,
705
+ )
706
+ click.echo(f"Expected file: {crm_pyproject_path}", err=True)
707
+ click.echo(
708
+ "This indicates the CRM module was not embedded correctly.",
709
+ err=True,
710
+ )
711
+ raise click.Abort()
712
+
713
+ try:
714
+ with open(crm_pyproject_path) as f:
715
+ crm_pyproject_content = f.read()
716
+
717
+ # Extract djangorestframework version
718
+ drf_version = None
719
+ if "djangorestframework" not in pyproject_content:
720
+ drf_match = re.search(
721
+ r'djangorestframework\s*=\s*["\']([^"\']+)["\']',
722
+ crm_pyproject_content,
723
+ )
724
+ if drf_match:
725
+ drf_version = drf_match.group(1)
726
+
727
+ # Extract django-filter version
728
+ filter_version = None
729
+ if "django-filter" not in pyproject_content:
730
+ filter_match = re.search(
731
+ r'django-filter\s*=\s*["\']([^"\']+)["\']',
732
+ crm_pyproject_content,
733
+ )
734
+ if filter_match:
735
+ filter_version = filter_match.group(1)
736
+
737
+ # Add dependencies if needed
738
+ if drf_version or filter_version:
739
+ dependencies_pattern = r"(\[tool\.poetry\.dependencies\][^\[]*)"
740
+ match = re.search(dependencies_pattern, pyproject_content, re.DOTALL)
741
+ if match:
742
+ dependencies_section = match.group(1)
743
+ additions = ""
744
+ if drf_version:
745
+ additions += f'\ndjangorestframework = "{drf_version}"'
746
+ if filter_version:
747
+ additions += f'\ndjango-filter = "{filter_version}"'
748
+
749
+ updated_dependencies = re.sub(
750
+ r'(python = "[^"]*")',
751
+ rf"\1{additions}",
752
+ dependencies_section,
753
+ )
754
+ pyproject_content = pyproject_content.replace(
755
+ dependencies_section, updated_dependencies
756
+ )
757
+
758
+ with open(pyproject_path, "w") as f:
759
+ f.write(pyproject_content)
760
+
761
+ if drf_version:
762
+ click.secho(
763
+ " ✅ Added djangorestframework to pyproject.toml", fg="green"
764
+ )
765
+ if filter_version:
766
+ click.secho(
767
+ " ✅ Added django-filter to pyproject.toml", fg="green"
768
+ )
769
+ except (FileNotFoundError, AttributeError) as e:
770
+ click.secho(
771
+ f"❌ Error: Failed to parse dependencies from CRM module: {e}",
772
+ fg="red",
773
+ err=True,
774
+ )
775
+ click.echo(f"File: {crm_pyproject_path}", err=True)
776
+ raise click.Abort()
777
+
778
+
779
+ def apply_crm_configuration(project_path: Path, config: dict[str, Any]) -> None:
780
+ """Apply CRM module configuration to project settings"""
781
+ # QuickScale uses settings/base.py and project_name/urls.py structure
782
+ settings_path = project_path / f"{project_path.name}" / "settings" / "base.py"
783
+ urls_path = project_path / f"{project_path.name}" / "urls.py"
784
+ pyproject_path = project_path / "pyproject.toml"
785
+
786
+ if not settings_path.exists():
787
+ click.secho(
788
+ "⚠️ Warning: settings.py not found, skipping auto-configuration",
789
+ fg="yellow",
790
+ )
791
+ return
792
+
793
+ # Read settings.py
794
+ with open(settings_path) as f:
795
+ settings_content = f.read()
796
+
797
+ # Check if already configured
798
+ if "quickscale_modules_crm" in settings_content:
799
+ click.echo("ℹ️ CRM module already configured in settings.py")
800
+ return
801
+
802
+ # Add DRF and django-filter dependencies to pyproject.toml
803
+ if pyproject_path.exists():
804
+ _add_drf_and_filter_dependencies(project_path, pyproject_path)
805
+
806
+ # Determine which apps need to be added (avoid duplicates)
807
+ required_apps = ["rest_framework", "django_filters", "quickscale_modules_crm"]
808
+ new_apps = _filter_new_apps(settings_content, required_apps)
809
+
810
+ if not new_apps:
811
+ click.echo("ℹ️ All required apps already in INSTALLED_APPS")
812
+ else:
813
+ # Build the INSTALLED_APPS addition with only new apps
814
+ apps_list = ",\n ".join([f'"{app}"' for app in new_apps])
815
+ installed_apps_addition = f"""
816
+ # QuickScale CRM Module - Added by quickscale embed
817
+ INSTALLED_APPS += [
818
+ {apps_list},
819
+ ]
820
+ """
821
+ # Append INSTALLED_APPS to settings.py
822
+ with open(settings_path, "a") as f:
823
+ f.write("\n" + installed_apps_addition)
824
+
825
+ # Add CRM settings (always add these)
826
+ settings_addition = f"""
827
+ # CRM Module Settings
828
+ CRM_DEALS_PER_PAGE = {config["deals_per_page"]}
829
+ CRM_CONTACTS_PER_PAGE = {config["contacts_per_page"]}
830
+ CRM_ENABLE_API = {config["enable_api"]}
831
+ """
832
+
833
+ # Add REST Framework settings if enabling API
834
+ if config["enable_api"]:
835
+ settings_addition += """
836
+ # REST Framework Settings
837
+ REST_FRAMEWORK = {
838
+ "DEFAULT_AUTHENTICATION_CLASSES": [
839
+ "rest_framework.authentication.SessionAuthentication",
840
+ ],
841
+ "DEFAULT_PERMISSION_CLASSES": [
842
+ "rest_framework.permissions.IsAuthenticated",
843
+ ],
844
+ "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
845
+ "PAGE_SIZE": 25,
846
+ "DEFAULT_FILTER_BACKENDS": [
847
+ "django_filters.rest_framework.DjangoFilterBackend",
848
+ "rest_framework.filters.SearchFilter",
849
+ "rest_framework.filters.OrderingFilter",
850
+ ],
851
+ }
852
+ """
853
+
854
+ # Append settings to settings.py
855
+ with open(settings_path, "a") as f:
856
+ f.write(settings_addition)
857
+
858
+ click.secho(" ✅ Updated settings.py with CRM configuration", fg="green")
859
+
860
+ # Update urls.py
861
+ if urls_path.exists():
862
+ with open(urls_path) as f:
863
+ urls_content = f.read()
864
+
865
+ if "quickscale_modules_crm" not in urls_content:
866
+ # Find urlpatterns and add CRM URLs
867
+ if "urlpatterns = [" in urls_content:
868
+ urls_addition = (
869
+ ' path("crm/", include("quickscale_modules_crm.urls")),\n'
870
+ )
871
+ urls_content = urls_content.replace(
872
+ "urlpatterns = [", "urlpatterns = [\n" + urls_addition
873
+ )
874
+
875
+ with open(urls_path, "w") as f:
876
+ f.write(urls_content)
877
+
878
+ click.secho(" ✅ Updated urls.py with CRM URLs", fg="green")
879
+
880
+ # Show configuration summary
881
+ click.echo("\n📋 Configuration applied:")
882
+ click.echo(f" • API: {'Enabled' if config['enable_api'] else 'Disabled'}")
883
+ click.echo(f" • Deals per page: {config['deals_per_page']}")
884
+ click.echo(f" • Contacts per page: {config['contacts_per_page']}")
885
+
886
+
605
887
  # ============================================================================
606
888
  # MODULE CONFIGURATORS REGISTRY
607
889
  # ============================================================================
@@ -610,4 +892,5 @@ MODULE_CONFIGURATORS = {
610
892
  "auth": (configure_auth_module, apply_auth_configuration),
611
893
  "blog": (configure_blog_module, apply_blog_configuration),
612
894
  "listings": (configure_listings_module, apply_listings_configuration),
895
+ "crm": (configure_crm_module, apply_crm_configuration),
613
896
  }
@@ -30,6 +30,7 @@ AVAILABLE_MODULES = [
30
30
  ("auth", "Authentication with django-allauth"),
31
31
  ("blog", "Markdown-powered blog with categories and RSS"),
32
32
  ("listings", "Generic listings for marketplace verticals"),
33
+ ("crm", "Customer Relationship Management (contacts, deals, pipeline)"),
33
34
  ("billing", "Stripe integration (placeholder)"),
34
35
  ("teams", "Multi-tenancy and team management (placeholder)"),
35
36
  ]
@@ -66,9 +67,9 @@ def _confirm_unimplemented_theme(theme_id: str) -> bool:
66
67
  def _select_theme() -> str:
67
68
  """Interactive theme selection"""
68
69
  click.echo("\n🎨 Select a theme for your project:")
69
- for i, (theme_id, description) in enumerate(AVAILABLE_THEMES, start=1):
70
- status = " ⚠️ not yet implemented" if theme_id != "showcase_html" else ""
71
- click.echo(f" {i}. {theme_id} - {description}{status}")
70
+ for i, (tid, description) in enumerate(AVAILABLE_THEMES, start=1):
71
+ status = " ⚠️ not yet implemented" if tid != "showcase_html" else ""
72
+ click.echo(f" {i}. {tid} - {description}{status}")
72
73
 
73
74
  while True:
74
75
  choice = click.prompt(
@@ -78,13 +79,13 @@ def _select_theme() -> str:
78
79
  )
79
80
 
80
81
  # Try numeric choice first
81
- theme_id = None
82
+ theme_id: str | None
82
83
  if choice.isdigit():
83
84
  theme_id = _get_theme_by_index(int(choice) - 1)
84
85
  else:
85
86
  theme_id = _get_theme_by_name(choice)
86
87
 
87
- if theme_id and _confirm_unimplemented_theme(theme_id):
88
+ if theme_id is not None and _confirm_unimplemented_theme(theme_id):
88
89
  return theme_id
89
90
 
90
91
  if theme_id is None:
@@ -298,8 +299,6 @@ def _parse_add_module_selection(
298
299
 
299
300
  return selected
300
301
 
301
- return selected
302
-
303
302
 
304
303
  def _select_modules_to_add(existing_modules: list[str]) -> list[str]:
305
304
  """Interactive module selection for adding to existing project
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  import click
10
10
  import yaml
11
11
 
12
- from quickscale_cli.schema.state_schema import StateManager
12
+ from quickscale_cli.schema.state_schema import QuickScaleState, StateManager
13
13
 
14
14
 
15
15
  def _update_quickscale_yml(project_path: Path, module_name: str) -> bool:
@@ -120,7 +120,7 @@ def _update_urls_py(project_path: Path, module_name: str) -> tuple[bool, str]:
120
120
 
121
121
  def _check_module_exists(
122
122
  project_path: Path, module_name: str, state_manager: StateManager
123
- ) -> tuple[bool, bool, object]:
123
+ ) -> tuple[bool, bool, QuickScaleState | None]:
124
124
  """Check if module exists in state or filesystem
125
125
 
126
126
  Returns:
@@ -135,7 +135,9 @@ def _check_module_exists(
135
135
  return module_in_state, module_in_filesystem, state
136
136
 
137
137
 
138
- def _show_module_not_found_error(module_name: str, state: object) -> None:
138
+ def _show_module_not_found_error(
139
+ module_name: str, state: QuickScaleState | None
140
+ ) -> None:
139
141
  """Display error when module is not found"""
140
142
  click.secho(
141
143
  f"❌ Module '{module_name}' is not installed in this project",
@@ -143,7 +145,7 @@ def _show_module_not_found_error(module_name: str, state: object) -> None:
143
145
  err=True,
144
146
  )
145
147
  click.echo("\n💡 Installed modules:", err=True)
146
- if state and hasattr(state, "modules") and state.modules:
148
+ if state and state.modules:
147
149
  for name in state.modules:
148
150
  click.echo(f" - {name}", err=True)
149
151
  else:
@@ -192,10 +194,10 @@ def _log_step_result(success: bool, message: str, is_error: bool = False) -> Non
192
194
 
193
195
 
194
196
  def _update_state_for_removal(
195
- state: object, module_name: str, state_manager: StateManager
197
+ state: QuickScaleState | None, module_name: str, state_manager: StateManager
196
198
  ) -> None:
197
199
  """Remove module from state and save"""
198
- if not (state and hasattr(state, "modules") and module_name in state.modules):
200
+ if not (state and module_name in state.modules):
199
201
  return
200
202
 
201
203
  del state.modules[module_name]
@@ -209,7 +211,7 @@ def _update_state_for_removal(
209
211
  def _perform_removal_steps(
210
212
  project_path: Path,
211
213
  module_name: str,
212
- state: object,
214
+ state: QuickScaleState | None,
213
215
  state_manager: StateManager,
214
216
  ) -> None:
215
217
  """Execute all removal steps"""
@@ -72,7 +72,7 @@ VALID_TOP_LEVEL_KEYS = {"version", "project", "modules", "docker"}
72
72
  VALID_PROJECT_KEYS = {"name", "theme"}
73
73
  VALID_DOCKER_KEYS = {"start", "build"}
74
74
  VALID_THEMES = {"showcase_html", "showcase_htmx", "showcase_react"}
75
- AVAILABLE_MODULES = {"auth", "billing", "teams", "blog", "listings"}
75
+ AVAILABLE_MODULES = {"auth", "billing", "teams", "blog", "listings", "crm"}
76
76
 
77
77
 
78
78
  def _find_line_number(yaml_content: str, key: str) -> int | None:
@@ -588,7 +588,7 @@ def check_poetry_lock_consistency() -> tuple[bool, str]:
588
588
 
589
589
  def fix_poetry_lock() -> tuple[bool, str]:
590
590
  """
591
- Fix poetry.lock by running poetry lock --no-update.
591
+ Fix poetry.lock by running poetry lock.
592
592
 
593
593
  Returns
594
594
  -------
@@ -598,34 +598,17 @@ def fix_poetry_lock() -> tuple[bool, str]:
598
598
 
599
599
  """
600
600
  try:
601
- # Prefer `--no-update` when available to avoid changing resolved versions.
602
601
  result = subprocess.run(
603
- ["poetry", "lock", "--no-update"],
602
+ ["poetry", "lock"],
604
603
  capture_output=True,
605
604
  text=True,
606
- timeout=60,
605
+ timeout=120,
607
606
  )
608
607
 
609
608
  if result.returncode == 0:
610
609
  return True, "poetry.lock updated successfully"
611
610
 
612
- # If the CLI does not support --no-update (older/newdifferent Poetry),
613
- # fall back to a plain `poetry lock` which is more widely supported.
614
- # Detect common failure patterns but always attempt fallback once.
615
- fallback = subprocess.run(
616
- ["poetry", "lock"],
617
- capture_output=True,
618
- text=True,
619
- timeout=120,
620
- )
621
-
622
- if fallback.returncode == 0:
623
- return True, "poetry.lock updated successfully (fallback)"
624
- else:
625
- return False, (
626
- f"Failed to update poetry.lock (--no-update stderr: {result.stderr}; "
627
- f"fallback stderr: {fallback.stderr})"
628
- )
611
+ return False, f"Failed to update poetry.lock: {result.stderr}"
629
612
  except subprocess.TimeoutExpired:
630
613
  return False, "poetry lock timed out"
631
614
  except (subprocess.SubprocessError, FileNotFoundError) as e: