quickscale-cli 0.65.0__tar.gz → 0.67.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: quickscale-cli
3
- Version: 0.65.0
3
+ Version: 0.67.0
4
4
  Summary: Command-line interface for QuickScale Django project generator
5
5
  Home-page: https://github.com/Experto-AI/quickscale
6
6
  License: Apache-2.0
@@ -18,6 +18,6 @@ Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Requires-Dist: click (>=8.3.0,<9.0.0)
21
- Requires-Dist: quickscale-core (>=0.65.0,<0.66.0)
21
+ Requires-Dist: quickscale-core (>=0.67.0,<0.68.0)
22
22
  Project-URL: Documentation, https://github.com/Experto-AI/quickscale/tree/main/docs
23
23
  Project-URL: Repository, https://github.com/Experto-AI/quickscale
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "quickscale-cli"
3
- version = "0.65.0"
3
+ version = "0.67.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"
@@ -24,7 +24,7 @@ packages = [{include = "quickscale_cli", from = "src"}]
24
24
  [tool.poetry.dependencies]
25
25
  python = "^3.10"
26
26
  click = "^8.3.0"
27
- quickscale-core = "^0.65.0"
27
+ quickscale-core = "^0.67.0"
28
28
 
29
29
  [tool.poetry.group.dev.dependencies]
30
30
  # Dev dependencies centralized in root pyproject.toml
@@ -1,2 +1,2 @@
1
1
  # Auto-generated by scripts/version_tool.sh
2
- __version__ = "0.65.0"
2
+ __version__ = "0.67.0"
@@ -1,8 +1,10 @@
1
1
  """Development lifecycle commands for QuickScale projects."""
2
2
 
3
+ import json
3
4
  import re
4
5
  import subprocess
5
6
  import sys
7
+ from pathlib import Path
6
8
 
7
9
  import click
8
10
 
@@ -42,6 +44,19 @@ def up(build: bool, no_cache: bool) -> None:
42
44
  click.echo("💡 Tip: Start Docker Desktop or the Docker daemon", err=True)
43
45
  sys.exit(1)
44
46
 
47
+ # Check if dependencies changed and suggest rebuild
48
+ if not build and _dependencies_changed_since_last_build():
49
+ click.secho(
50
+ "⚠️ Warning: Dependencies may have changed since last Docker build",
51
+ fg="yellow",
52
+ bold=True,
53
+ )
54
+ click.echo(
55
+ " This can happen after embedding modules or updating dependencies.\n"
56
+ " If you encounter import errors, rebuild the image:\n"
57
+ )
58
+ click.secho(" quickscale down && quickscale up --build\n", fg="cyan")
59
+
45
60
  # Check if required port is available BEFORE calling docker-compose
46
61
  port = get_port_from_env()
47
62
  if not is_port_available(port):
@@ -62,7 +77,12 @@ def up(build: bool, no_cache: bool) -> None:
62
77
 
63
78
  try:
64
79
  compose_cmd = get_docker_compose_command()
65
- cmd = compose_cmd + ["up", "-d"]
80
+
81
+ # Use --progress plain during build for cleaner output without repetitive updates
82
+ if build or no_cache:
83
+ cmd = compose_cmd + ["--progress", "plain", "up", "-d"]
84
+ else:
85
+ cmd = compose_cmd + ["up", "-d"]
66
86
 
67
87
  if build or no_cache:
68
88
  cmd.append("--build")
@@ -71,10 +91,23 @@ def up(build: bool, no_cache: bool) -> None:
71
91
  cmd.append("--no-cache")
72
92
 
73
93
  click.echo("🚀 Starting Docker services...")
74
- subprocess.run(cmd, check=True, capture_output=True, text=True)
94
+
95
+ # Show output during build operations for transparency
96
+ # Capture output for simple startups to keep it clean
97
+ if build or no_cache:
98
+ click.echo("📦 Building Docker images...")
99
+ click.echo("") # Blank line for readability
100
+ subprocess.run(cmd, check=True, text=True)
101
+ else:
102
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
103
+
75
104
  click.secho("✅ Services started successfully!", fg="green", bold=True)
76
105
  click.echo("💡 Tip: Use 'quickscale logs' to view service logs")
77
106
 
107
+ # Update build timestamp if build was performed
108
+ if build:
109
+ _update_last_build_timestamp()
110
+
78
111
  except subprocess.CalledProcessError as e:
79
112
  # Check for port conflict in error output
80
113
  # Docker error format: "Bind for 0.0.0.0:8000 failed: port is already allocated"
@@ -372,3 +405,76 @@ def ps() -> None:
372
405
  err=True,
373
406
  )
374
407
  sys.exit(1)
408
+
409
+
410
+ def _get_build_state_file() -> Path:
411
+ """Get path to build state tracking file."""
412
+ return Path.cwd() / ".quickscale" / "build_state.json"
413
+
414
+
415
+ def _dependencies_changed_since_last_build() -> bool:
416
+ """
417
+ Check if pyproject.toml or poetry.lock changed since last Docker build.
418
+
419
+ Returns
420
+ -------
421
+ True if dependencies may have changed, False otherwise
422
+ """
423
+ build_state_file = _get_build_state_file()
424
+ pyproject_file = Path.cwd() / "pyproject.toml"
425
+ poetry_lock_file = Path.cwd() / "poetry.lock"
426
+
427
+ # If build state file doesn't exist, we can't determine if changed
428
+ # (likely first time running, or old project)
429
+ if not build_state_file.exists():
430
+ return False
431
+
432
+ # If dependency files don't exist, something is wrong but don't warn
433
+ if not pyproject_file.exists() or not poetry_lock_file.exists():
434
+ return False
435
+
436
+ try:
437
+ with open(build_state_file) as f:
438
+ build_state = json.load(f)
439
+
440
+ last_pyproject_mtime: float = build_state.get("pyproject_mtime", 0)
441
+ last_poetry_lock_mtime: float = build_state.get("poetry_lock_mtime", 0)
442
+
443
+ current_pyproject_mtime = pyproject_file.stat().st_mtime
444
+ current_poetry_lock_mtime = poetry_lock_file.stat().st_mtime
445
+
446
+ # Return True if either file changed since last build
447
+ changed: bool = (
448
+ current_pyproject_mtime > last_pyproject_mtime
449
+ or current_poetry_lock_mtime > last_poetry_lock_mtime
450
+ )
451
+ return changed
452
+
453
+ except (json.JSONDecodeError, KeyError, OSError):
454
+ # If we can't read state, don't warn (fail safe)
455
+ return False
456
+
457
+
458
+ def _update_last_build_timestamp() -> None:
459
+ """Update build state file with current dependency file timestamps."""
460
+ build_state_file = _get_build_state_file()
461
+ pyproject_file = Path.cwd() / "pyproject.toml"
462
+ poetry_lock_file = Path.cwd() / "poetry.lock"
463
+
464
+ # Ensure .quickscale directory exists
465
+ build_state_file.parent.mkdir(parents=True, exist_ok=True)
466
+
467
+ # Get current timestamps
468
+ pyproject_mtime = pyproject_file.stat().st_mtime if pyproject_file.exists() else 0
469
+ poetry_lock_mtime = (
470
+ poetry_lock_file.stat().st_mtime if poetry_lock_file.exists() else 0
471
+ )
472
+
473
+ # Write build state
474
+ build_state = {
475
+ "pyproject_mtime": pyproject_mtime,
476
+ "poetry_lock_mtime": poetry_lock_mtime,
477
+ }
478
+
479
+ with open(build_state_file, "w") as f:
480
+ json.dump(build_state, f, indent=2)
@@ -18,7 +18,7 @@ from quickscale_core.utils.git_utils import (
18
18
  )
19
19
 
20
20
  # Available modules
21
- AVAILABLE_MODULES = ["auth", "billing", "teams"]
21
+ AVAILABLE_MODULES = ["auth", "billing", "teams", "blog", "listings"]
22
22
 
23
23
 
24
24
  def has_migrations_been_run() -> bool:
@@ -273,8 +273,277 @@ SITE_ID = 1
273
273
  click.echo(f" • Authentication: {config['authentication_method']}")
274
274
 
275
275
 
276
+ def configure_blog_module() -> dict[str, Any]:
277
+ """Interactive configuration for blog module"""
278
+ click.echo("\n⚙️ Configuring blog module...")
279
+ click.echo("The blog module will be configured with default settings.\n")
280
+
281
+ config = {
282
+ "posts_per_page": click.prompt(
283
+ "Posts per page",
284
+ type=int,
285
+ default=10,
286
+ ),
287
+ "enable_rss": click.confirm("Enable RSS feed?", default=True),
288
+ }
289
+
290
+ return config
291
+
292
+
293
+ def apply_blog_configuration(project_path: Path, config: dict[str, Any]) -> None:
294
+ """Apply blog module configuration to project settings"""
295
+ # QuickScale uses settings/base.py and project_name/urls.py structure
296
+ settings_path = project_path / f"{project_path.name}" / "settings" / "base.py"
297
+ urls_path = project_path / f"{project_path.name}" / "urls.py"
298
+
299
+ if not settings_path.exists():
300
+ click.secho(
301
+ "⚠️ Warning: settings.py not found, skipping auto-configuration",
302
+ fg="yellow",
303
+ )
304
+ return
305
+
306
+ # Read settings.py
307
+ with open(settings_path) as f:
308
+ settings_content = f.read()
309
+
310
+ # Check if already configured
311
+ if "quickscale_modules_blog" in settings_content:
312
+ click.echo("ℹ️ Blog module already configured in settings.py")
313
+ return
314
+
315
+ # Add required apps to INSTALLED_APPS
316
+ installed_apps_addition = """
317
+ # QuickScale Blog Module - Added by quickscale embed
318
+ INSTALLED_APPS += [
319
+ "markdownx", # Markdown editor with image upload
320
+ "quickscale_modules_blog", # Blog module
321
+ ]
322
+
323
+ # Blog Module Settings
324
+ """
325
+
326
+ installed_apps_addition += f"BLOG_POSTS_PER_PAGE = {config['posts_per_page']}\n"
327
+ installed_apps_addition += """MARKDOWNX_MARKDOWN_EXTENSIONS = [
328
+ "markdown.extensions.fenced_code",
329
+ "markdown.extensions.tables",
330
+ "markdown.extensions.toc",
331
+ ]
332
+ MARKDOWNX_MEDIA_PATH = "blog/markdownx/"
333
+ """
334
+
335
+ # Append to settings.py
336
+ with open(settings_path, "a") as f:
337
+ f.write("\n" + installed_apps_addition)
338
+
339
+ click.secho(" ✅ Updated settings.py with blog configuration", fg="green")
340
+
341
+ # Update urls.py
342
+ if urls_path.exists():
343
+ with open(urls_path) as f:
344
+ urls_content = f.read()
345
+
346
+ if "quickscale_modules_blog" not in urls_content:
347
+ # Find urlpatterns and add blog URLs
348
+ if "urlpatterns = [" in urls_content:
349
+ urls_addition = (
350
+ ' path("blog/", include("quickscale_modules_blog.urls")),\n'
351
+ )
352
+ if config["enable_rss"]:
353
+ urls_addition += ' path("markdownx/", include("markdownx.urls")), # Markdown editor upload\n'
354
+ urls_content = urls_content.replace(
355
+ "urlpatterns = [", "urlpatterns = [\n" + urls_addition
356
+ )
357
+
358
+ with open(urls_path, "w") as f:
359
+ f.write(urls_content)
360
+
361
+ click.secho(" ✅ Updated urls.py with blog URLs", fg="green")
362
+
363
+ # Show configuration summary
364
+ click.echo("\n📋 Configuration applied:")
365
+ click.echo(f" • Posts per page: {config['posts_per_page']}")
366
+ click.echo(f" • RSS feed: {'Enabled' if config['enable_rss'] else 'Disabled'}")
367
+
368
+
369
+ def configure_listings_module() -> dict[str, Any]:
370
+ """Interactive configuration for listings module"""
371
+ click.echo("\n⚙️ Configuring listings module...")
372
+ click.echo(
373
+ "The listings module provides an abstract base model for marketplace listings.\n"
374
+ )
375
+
376
+ config = {
377
+ "listings_per_page": click.prompt(
378
+ "Listings per page",
379
+ type=int,
380
+ default=12,
381
+ ),
382
+ }
383
+
384
+ return config
385
+
386
+
387
+ def apply_listings_configuration(project_path: Path, config: dict[str, Any]) -> None:
388
+ """Apply listings module configuration to project settings"""
389
+ import re
390
+
391
+ # QuickScale uses settings/base.py and project_name/urls.py structure
392
+ settings_path = project_path / f"{project_path.name}" / "settings" / "base.py"
393
+ urls_path = project_path / f"{project_path.name}" / "urls.py"
394
+ pyproject_path = project_path / "pyproject.toml"
395
+
396
+ if not settings_path.exists():
397
+ click.secho(
398
+ "⚠️ Warning: settings.py not found, skipping auto-configuration",
399
+ fg="yellow",
400
+ )
401
+ return
402
+
403
+ # Read settings.py
404
+ with open(settings_path) as f:
405
+ settings_content = f.read()
406
+
407
+ # Check if already configured
408
+ if "quickscale_modules_listings" in settings_content:
409
+ click.echo("ℹ️ Listings module already configured in settings.py")
410
+ return
411
+
412
+ # Add django-filter dependency to pyproject.toml
413
+ if pyproject_path.exists():
414
+ with open(pyproject_path) as f:
415
+ pyproject_content = f.read()
416
+
417
+ if "django-filter" not in pyproject_content:
418
+ # Read django-filter version from the embedded listings module
419
+ listings_pyproject_path = (
420
+ project_path / "modules" / "listings" / "pyproject.toml"
421
+ )
422
+
423
+ if not listings_pyproject_path.exists():
424
+ click.secho(
425
+ "❌ Error: Listings module pyproject.toml not found. "
426
+ "Cannot determine django-filter version requirement.",
427
+ fg="red",
428
+ err=True,
429
+ )
430
+ click.echo(f"Expected file: {listings_pyproject_path}", err=True)
431
+ click.echo(
432
+ "This indicates the listings module was not embedded correctly.",
433
+ err=True,
434
+ )
435
+ raise click.Abort()
436
+
437
+ # Extract django-filter version using regex
438
+ try:
439
+ with open(listings_pyproject_path) as f:
440
+ listings_pyproject_content = f.read()
441
+
442
+ version_match = re.search(
443
+ r'django-filter\s*=\s*["\']([^"\']+)["\']',
444
+ listings_pyproject_content,
445
+ )
446
+ if not version_match:
447
+ click.secho(
448
+ "❌ Error: Cannot find django-filter version in listings "
449
+ "module's pyproject.toml",
450
+ fg="red",
451
+ err=True,
452
+ )
453
+ click.echo(f"File: {listings_pyproject_path}", err=True)
454
+ click.echo('Expected format: django-filter = "^x.x.x"', err=True)
455
+ click.echo(
456
+ "Please check the listings module's dependencies.", err=True
457
+ )
458
+ raise click.Abort()
459
+ django_filter_version = version_match.group(1)
460
+ except (FileNotFoundError, AttributeError) as e:
461
+ click.secho(
462
+ f"❌ Error: Failed to parse django-filter version from listings "
463
+ f"module: {e}",
464
+ fg="red",
465
+ err=True,
466
+ )
467
+ click.echo(f"File: {listings_pyproject_path}", err=True)
468
+ click.echo(
469
+ "Please ensure the listings module is properly embedded and its "
470
+ "pyproject.toml is valid.",
471
+ err=True,
472
+ )
473
+ raise click.Abort()
474
+
475
+ # Try to add to [tool.poetry.dependencies] section
476
+ dependencies_pattern = r"(\[tool\.poetry\.dependencies\][^\[]*)"
477
+ match = re.search(dependencies_pattern, pyproject_content, re.DOTALL)
478
+ if match:
479
+ dependencies_section = match.group(1)
480
+ # Add django-filter after the python version line
481
+ updated_dependencies = re.sub(
482
+ r'(python = "[^"]*")',
483
+ rf'\1\ndjango-filter = "{django_filter_version}"',
484
+ dependencies_section,
485
+ )
486
+ pyproject_content = pyproject_content.replace(
487
+ dependencies_section, updated_dependencies
488
+ )
489
+
490
+ with open(pyproject_path, "w") as f:
491
+ f.write(pyproject_content)
492
+
493
+ click.secho(" ✅ Added django-filter to pyproject.toml", fg="green")
494
+ else:
495
+ click.secho(
496
+ "⚠️ Warning: Could not find [tool.poetry.dependencies] section in "
497
+ "pyproject.toml",
498
+ fg="yellow",
499
+ )
500
+
501
+ # Add required apps to INSTALLED_APPS
502
+ installed_apps_addition = """
503
+ # QuickScale Listings Module - Added by quickscale embed
504
+ INSTALLED_APPS += [
505
+ "django_filters", # Filtering support
506
+ "quickscale_modules_listings", # Listings module
507
+ ]
508
+
509
+ # Listings Module Settings
510
+ """
511
+
512
+ installed_apps_addition += f"LISTINGS_PER_PAGE = {config['listings_per_page']}\n"
513
+
514
+ # Append to settings.py
515
+ with open(settings_path, "a") as f:
516
+ f.write("\n" + installed_apps_addition)
517
+
518
+ click.secho(" ✅ Updated settings.py with listings configuration", fg="green")
519
+
520
+ # Update urls.py
521
+ if urls_path.exists():
522
+ with open(urls_path) as f:
523
+ urls_content = f.read()
524
+
525
+ if "quickscale_modules_listings" not in urls_content:
526
+ # Find urlpatterns and add listings URLs
527
+ if "urlpatterns = [" in urls_content:
528
+ urls_addition = ' path("listings/", include("quickscale_modules_listings.urls")),\n'
529
+ urls_content = urls_content.replace(
530
+ "urlpatterns = [", "urlpatterns = [\n" + urls_addition
531
+ )
532
+
533
+ with open(urls_path, "w") as f:
534
+ f.write(urls_content)
535
+
536
+ click.secho(" ✅ Updated urls.py with listings URLs", fg="green")
537
+
538
+ # Show configuration summary
539
+ click.echo("\n📋 Configuration applied:")
540
+ click.echo(f" • Listings per page: {config['listings_per_page']}")
541
+
542
+
276
543
  MODULE_CONFIGURATORS = {
277
544
  "auth": (configure_auth_module, apply_auth_configuration),
545
+ "blog": (configure_blog_module, apply_blog_configuration),
546
+ "listings": (configure_listings_module, apply_listings_configuration),
278
547
  }
279
548
 
280
549
 
@@ -298,16 +567,20 @@ def embed(module: str, remote: str) -> None:
298
567
  Examples:
299
568
  quickscale embed --module auth
300
569
  quickscale embed --module billing
570
+ quickscale embed --module blog
571
+ quickscale embed --module listings
301
572
 
302
573
  \b
303
- Available modules (v0.62.0 placeholders):
304
- - auth: Authentication with django-allauth (placeholder)
574
+ Available modules:
575
+ - auth: Authentication with django-allauth
305
576
  - billing: Stripe integration with dj-stripe (placeholder)
306
577
  - teams: Multi-tenancy and team management (placeholder)
578
+ - blog: Markdown-powered blog with categories, tags, and RSS
579
+ - listings: Generic listings with filtering for marketplace verticals
307
580
 
308
581
  \b
309
- Note: In v0.62.0, modules contain only placeholder READMEs explaining
310
- they're not yet implemented. Full implementations coming in v0.63.0+.
582
+ Note: Auth, blog, and listings modules are fully implemented.
583
+ Billing and teams modules contain placeholder READMEs.
311
584
  """
312
585
  try:
313
586
  # Validate git repository
@@ -420,37 +693,117 @@ def embed(module: str, remote: str) -> None:
420
693
  project_root = Path.cwd()
421
694
  applier(project_root, config)
422
695
 
423
- # Install dependencies if pyproject.toml was modified
424
- if module == "auth":
425
- click.echo("📦 Installing dependencies...")
696
+ # Install dependencies for modules that need it
697
+ if module in ["auth", "blog", "listings"]:
698
+ click.echo("\n📦 Installing dependencies...")
426
699
  try:
427
700
  import subprocess
428
701
 
429
- # Install the auth module
430
- subprocess.run(
431
- ["poetry", "add", "./modules/auth"],
702
+ project_root = Path.cwd()
703
+ module_path = project_root / "modules" / module
704
+
705
+ # Verify module was actually embedded
706
+ if not module_path.exists():
707
+ click.secho(
708
+ f"❌ Error: Module directory not found at {module_path}",
709
+ fg="red",
710
+ err=True,
711
+ )
712
+ click.echo(
713
+ " The git subtree add may have failed. Check the output above.",
714
+ err=True,
715
+ )
716
+ raise click.Abort()
717
+
718
+ # Determine the correct path to add
719
+ # Sometimes the split branch might contain the full repo (e.g. during dev/testing)
720
+ # We need to find the actual module's pyproject.toml
721
+ target_path = module_path
722
+ nested_path = module_path / "quickscale_modules" / module
723
+
724
+ if nested_path.exists() and (nested_path / "pyproject.toml").exists():
725
+ click.secho(
726
+ f"⚠️ Warning: Detected full repository in {module} module path.",
727
+ fg="yellow",
728
+ )
729
+ click.echo(
730
+ f" Using nested path: {nested_path.relative_to(project_root)}"
731
+ )
732
+ target_path = nested_path
733
+
734
+ # Install the module
735
+ click.echo(f" • Installing {module} module...")
736
+ result = subprocess.run(
737
+ ["poetry", "add", f"./{target_path.relative_to(project_root)}"],
432
738
  cwd=project_root,
433
739
  capture_output=True,
434
740
  text=True,
435
- check=True,
436
741
  )
437
- click.secho(" ✅ Auth module installed successfully", fg="green")
742
+
743
+ if result.returncode != 0:
744
+ click.secho(
745
+ f"\n❌ Failed to install {module} module",
746
+ fg="red",
747
+ err=True,
748
+ bold=True,
749
+ )
750
+ click.echo("\n📋 Error output (stderr):", err=True)
751
+ click.echo(result.stderr, err=True)
752
+ click.echo("\n📋 Standard output (stdout):", err=True)
753
+ click.echo(result.stdout, err=True)
754
+
755
+ click.echo("\n💡 To fix this manually:", err=True)
756
+ click.echo(f" 1. cd {project_root}", err=True)
757
+ click.echo(f" 2. poetry add ./modules/{module}", err=True)
758
+ click.echo(" 3. poetry install", err=True)
759
+ click.echo(" 4. poetry run python manage.py migrate", err=True)
760
+ raise click.Abort()
761
+
762
+ click.secho(
763
+ f" ✅ {module.capitalize()} module installed successfully",
764
+ fg="green",
765
+ )
438
766
 
439
767
  # Install dependencies
440
- subprocess.run(
768
+ click.echo(" • Installing all dependencies...")
769
+ result = subprocess.run(
441
770
  ["poetry", "install"],
442
771
  cwd=project_root,
443
772
  capture_output=True,
444
773
  text=True,
445
- check=True,
446
774
  )
775
+
776
+ if result.returncode != 0:
777
+ click.secho(
778
+ "\n❌ Failed to install dependencies",
779
+ fg="red",
780
+ err=True,
781
+ bold=True,
782
+ )
783
+ click.echo("\n📋 Error output (stderr):", err=True)
784
+ click.echo(result.stderr, err=True)
785
+ click.echo("\n📋 Standard output (stdout):", err=True)
786
+ click.echo(result.stdout, err=True)
787
+
788
+ click.echo("\n💡 To fix this manually:", err=True)
789
+ click.echo(f" 1. cd {project_root}", err=True)
790
+ click.echo(" 2. poetry install", err=True)
791
+ click.echo(" 3. poetry run python manage.py migrate", err=True)
792
+ raise click.Abort()
793
+
447
794
  click.secho(" ✅ Dependencies installed successfully", fg="green")
795
+
448
796
  except subprocess.CalledProcessError as e:
449
797
  click.secho(
450
- f"⚠️ Warning: Failed to install dependencies automatically: {e}",
451
- fg="yellow",
798
+ f"\n❌ Unexpected error during dependency installation: {e}",
799
+ fg="red",
800
+ err=True,
801
+ )
802
+ click.echo(
803
+ f"\n💡 Try running 'poetry install' manually in {project_root}",
804
+ err=True,
452
805
  )
453
- click.echo(" 💡 You may need to run 'poetry install' manually")
806
+ raise click.Abort()
454
807
 
455
808
  # Success message
456
809
  click.secho(
@@ -475,10 +828,96 @@ def embed(module: str, remote: str) -> None:
475
828
  " 3. Create superuser (optional): poetry run python manage.py createsuperuser"
476
829
  )
477
830
  click.echo(" 4. Start development server:")
478
- click.echo(" • With Docker: quickscale up")
831
+ click.echo("")
832
+ click.secho(
833
+ " ⚠️ IMPORTANT: Use --build flag with Docker",
834
+ fg="yellow",
835
+ bold=True,
836
+ )
837
+ click.secho(
838
+ " • With Docker: quickscale down && quickscale up --build",
839
+ fg="cyan",
840
+ bold=True,
841
+ )
842
+ click.secho(
843
+ " ^^^ --build is REQUIRED to install new dependencies",
844
+ fg="yellow",
845
+ )
846
+ click.echo("")
479
847
  click.echo(" • Without Docker: poetry run python manage.py runserver")
480
848
  click.echo(" 5. Visit http://localhost:8000/accounts/login/")
481
- click.echo("\n� Documentation: modules/auth/README.md")
849
+ click.echo("\n📖 Documentation: modules/auth/README.md")
850
+ elif module == "blog":
851
+ click.echo(f" 1. Review module code in modules/{module}/")
852
+ click.secho(
853
+ " 2. ⚠️ IMPORTANT: Run migrations (required before server start):",
854
+ fg="yellow",
855
+ bold=True,
856
+ )
857
+ click.secho(
858
+ " poetry run python manage.py migrate", fg="cyan", bold=True
859
+ )
860
+ click.echo(
861
+ " 3. Create superuser (optional): poetry run python manage.py createsuperuser"
862
+ )
863
+ click.echo(" 4. Start development server:")
864
+ click.echo("")
865
+ click.secho(
866
+ " ⚠️ IMPORTANT: Use --build flag with Docker",
867
+ fg="yellow",
868
+ bold=True,
869
+ )
870
+ click.secho(
871
+ " • With Docker: quickscale down && quickscale up --build",
872
+ fg="cyan",
873
+ bold=True,
874
+ )
875
+ click.secho(
876
+ " ^^^ --build is REQUIRED to install new dependencies",
877
+ fg="yellow",
878
+ )
879
+ click.echo("")
880
+ click.echo(" • Without Docker: poetry run python manage.py runserver")
881
+ click.echo(" 5. Visit http://localhost:8000/admin/ to create blog posts")
882
+ click.echo(" 6. View your blog at http://localhost:8000/blog/")
883
+ click.echo(" 7. RSS feed available at http://localhost:8000/blog/feed/")
884
+ click.echo("\n📖 Documentation: modules/blog/README.md")
885
+ elif module == "listings":
886
+ click.echo(f" 1. Review module code in modules/{module}/")
887
+ click.secho(
888
+ " 2. ⚠️ IMPORTANT: Run migrations (required before server start):",
889
+ fg="yellow",
890
+ bold=True,
891
+ )
892
+ click.secho(
893
+ " poetry run python manage.py migrate", fg="cyan", bold=True
894
+ )
895
+ click.echo(
896
+ " 3. Create superuser (optional): poetry run python manage.py createsuperuser"
897
+ )
898
+ click.echo(" 4. Start development server:")
899
+ click.echo("")
900
+ click.secho(
901
+ " ⚠️ IMPORTANT: Use --build flag with Docker",
902
+ fg="yellow",
903
+ bold=True,
904
+ )
905
+ click.secho(
906
+ " • With Docker: quickscale down && quickscale up --build",
907
+ fg="cyan",
908
+ bold=True,
909
+ )
910
+ click.secho(
911
+ " ^^^ --build is REQUIRED to install new dependencies",
912
+ fg="yellow",
913
+ )
914
+ click.echo("")
915
+ click.echo(" • Without Docker: poetry run python manage.py runserver")
916
+ click.echo(
917
+ " 5. Create a concrete model extending AbstractListing (see README)"
918
+ )
919
+ click.echo(" 6. View listings at http://localhost:8000/listings/")
920
+ click.echo("\n📖 Documentation: modules/listings/README.md")
482
921
  else:
483
922
  click.echo(f" 1. Review the module code in modules/{module}/")
484
923
  click.echo(f" 2. Follow setup instructions in modules/{module}/README.md")
@@ -97,8 +97,8 @@ def init(project_name: str, theme: str) -> None:
97
97
  \b
98
98
  Available themes:
99
99
  showcase_html Pure HTML + CSS (default, production-ready)
100
- showcase_htmx HTMX + Alpine.js (coming in v0.67.0)
101
- showcase_react React + TypeScript SPA (coming in v0.68.0)
100
+ showcase_htmx HTMX + Alpine.js (coming in v0.70.0)
101
+ showcase_react React + TypeScript SPA (coming in v0.71.0)
102
102
  """
103
103
  # Step 1: Check system dependencies BEFORE generation
104
104
  click.echo("🔍 Checking system dependencies...")
@@ -145,8 +145,8 @@ def init(project_name: str, theme: str) -> None:
145
145
  click.echo(
146
146
  f"\n💡 The '{theme}' theme is planned for a future release:", err=True
147
147
  )
148
- click.echo(" - showcase_htmx: Coming in v0.67.0", err=True)
149
- click.echo(" - showcase_react: Coming in v0.68.0", err=True)
148
+ click.echo(" - showcase_htmx: Coming in v0.70.0", err=True)
149
+ click.echo(" - showcase_react: Coming in v0.71.0", err=True)
150
150
  click.echo("\n📖 For now, use the default 'showcase_html' theme", err=True)
151
151
  raise click.Abort()
152
152
 
@@ -1,10 +1,36 @@
1
1
  """System dependency checking utilities."""
2
2
 
3
+ import os
3
4
  import shutil
4
5
  import subprocess
5
6
  from typing import NamedTuple
6
7
 
7
8
 
9
+ SKIP_ENV_VAR = "QUICKSCALE_SKIP_DEPENDENCY_CHECKS"
10
+
11
+
12
+ def _should_skip_dependency_checks() -> bool:
13
+ """Return True when dependency checks should be skipped."""
14
+
15
+ value = os.getenv(SKIP_ENV_VAR, "")
16
+ return value.strip().lower() in {"1", "true", "yes", "on"}
17
+
18
+
19
+ def _skipped_dependency(
20
+ name: str, required: bool, purpose: str, version_env: str | None = None
21
+ ) -> "DependencyStatus":
22
+ """Create a dependency status representing a skipped check."""
23
+
24
+ version = os.getenv(version_env) if version_env else None
25
+ return DependencyStatus(
26
+ name=name,
27
+ installed=True,
28
+ version=version,
29
+ required=required,
30
+ purpose=purpose,
31
+ )
32
+
33
+
8
34
  class DependencyStatus(NamedTuple):
9
35
  """Status of a system dependency check."""
10
36
 
@@ -35,6 +61,14 @@ def check_python_version() -> DependencyStatus:
35
61
 
36
62
  def check_poetry_installed() -> DependencyStatus:
37
63
  """Check if Poetry is installed."""
64
+
65
+ if _should_skip_dependency_checks():
66
+ return _skipped_dependency(
67
+ name="Poetry",
68
+ required=True,
69
+ purpose="Dependency management for generated projects",
70
+ version_env="QUICKSCALE_MOCK_POETRY_VERSION",
71
+ )
38
72
  try:
39
73
  result = subprocess.run(
40
74
  ["poetry", "--version"],
@@ -66,6 +100,14 @@ def check_poetry_installed() -> DependencyStatus:
66
100
 
67
101
  def check_git_installed() -> DependencyStatus:
68
102
  """Check if Git is installed."""
103
+
104
+ if _should_skip_dependency_checks():
105
+ return _skipped_dependency(
106
+ name="Git",
107
+ required=False,
108
+ purpose="Version control and module management (quickscale embed/update)",
109
+ version_env="QUICKSCALE_MOCK_GIT_VERSION",
110
+ )
69
111
  git_path = shutil.which("git")
70
112
 
71
113
  if git_path:
@@ -105,7 +147,15 @@ def check_git_installed() -> DependencyStatus:
105
147
 
106
148
 
107
149
  def check_docker_installed() -> DependencyStatus:
108
- """Check if Docker is installed."""
150
+ """Check if Docker and Docker Compose are installed."""
151
+
152
+ if _should_skip_dependency_checks():
153
+ return _skipped_dependency(
154
+ name="Docker",
155
+ required=False,
156
+ purpose="Containerized development (quickscale up/down)",
157
+ version_env="QUICKSCALE_MOCK_DOCKER_VERSION",
158
+ )
109
159
  docker_path = shutil.which("docker")
110
160
 
111
161
  if docker_path:
@@ -122,6 +172,47 @@ def check_docker_installed() -> DependencyStatus:
122
172
  parts = version_str.split()
123
173
  version = parts[2].rstrip(",") if len(parts) >= 3 else None
124
174
 
175
+ # Also check for Docker Compose (either v2 plugin or v1 standalone)
176
+ compose_available = False
177
+ try:
178
+ # Try docker compose v2 (plugin)
179
+ subprocess.run(
180
+ ["docker", "compose", "version"],
181
+ capture_output=True,
182
+ check=True,
183
+ timeout=2,
184
+ )
185
+ compose_available = True
186
+ except (
187
+ subprocess.SubprocessError,
188
+ FileNotFoundError,
189
+ subprocess.TimeoutExpired,
190
+ ):
191
+ # Try docker-compose v1 (standalone)
192
+ try:
193
+ subprocess.run(
194
+ ["docker-compose", "--version"],
195
+ capture_output=True,
196
+ check=True,
197
+ timeout=2,
198
+ )
199
+ compose_available = True
200
+ except (
201
+ subprocess.SubprocessError,
202
+ FileNotFoundError,
203
+ subprocess.TimeoutExpired,
204
+ ):
205
+ pass
206
+
207
+ if not compose_available:
208
+ return DependencyStatus(
209
+ name="Docker",
210
+ installed=False,
211
+ version=version,
212
+ required=False,
213
+ purpose="Containerized development (quickscale up/down) - Docker Compose not found",
214
+ )
215
+
125
216
  return DependencyStatus(
126
217
  name="Docker",
127
218
  installed=True,
@@ -147,6 +238,14 @@ def check_docker_installed() -> DependencyStatus:
147
238
 
148
239
  def check_postgresql_installed() -> DependencyStatus:
149
240
  """Check if PostgreSQL client is installed."""
241
+
242
+ if _should_skip_dependency_checks():
243
+ return _skipped_dependency(
244
+ name="PostgreSQL",
245
+ required=False,
246
+ purpose="Database server (required for production, Docker provides for dev)",
247
+ version_env="QUICKSCALE_MOCK_POSTGRES_VERSION",
248
+ )
150
249
  psql_path = shutil.which("psql")
151
250
 
152
251
  if psql_path:
@@ -28,16 +28,20 @@ def find_docker_compose() -> Path | None:
28
28
 
29
29
 
30
30
  def get_docker_compose_command() -> list[str]:
31
- """Get the appropriate docker compose command."""
32
- # Try docker-compose first, fall back to docker compose
31
+ """Get the appropriate docker compose command.
32
+
33
+ Tries 'docker compose' (v2 plugin) first, falls back to 'docker-compose' (v1 standalone).
34
+ Both are fully supported.
35
+ """
36
+ # Try docker compose first (v2 plugin)
33
37
  try:
34
38
  subprocess.run(
35
- ["docker-compose", "--version"], capture_output=True, check=True, timeout=2
39
+ ["docker", "compose", "version"], capture_output=True, check=True, timeout=2
36
40
  )
37
- return ["docker-compose"]
38
- except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired):
39
- # Fall back to docker compose (newer Docker versions)
40
41
  return ["docker", "compose"]
42
+ except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired):
43
+ # Fall back to docker-compose (v1 standalone)
44
+ return ["docker-compose"]
41
45
 
42
46
 
43
47
  def get_container_status(container_name: str) -> str | None: