qx-cli 0.2.0__tar.gz → 1.1.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 (109) hide show
  1. {qx_cli-0.2.0 → qx_cli-1.1.0}/PKG-INFO +2 -1
  2. {qx_cli-0.2.0 → qx_cli-1.1.0}/pyproject.toml +2 -1
  3. qx_cli-1.1.0/src/qx/cli/commands/dlq.py +231 -0
  4. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/doctor.py +39 -3
  5. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/generate.py +104 -18
  6. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/new.py +36 -5
  7. qx_cli-1.1.0/src/qx/cli/commands/projections.py +231 -0
  8. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/main.py +15 -1
  9. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +4 -3
  10. qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application/__slice_name_snake__/__init__.py +0 -0
  11. qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application/__slice_name_snake__/commands/__init__.py +0 -0
  12. qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application/__slice_name_snake__/commands/__name_snake__.py.j2 +28 -0
  13. qx_cli-1.1.0/src/qx/cli/scaffolds/endpoint/__init__.py +0 -0
  14. qx_cli-1.1.0/src/qx/cli/scaffolds/esaggregate/__init__.py +0 -0
  15. qx_cli-1.1.0/src/qx/cli/scaffolds/esaggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +85 -0
  16. qx_cli-1.1.0/src/qx/cli/scaffolds/event/__init__.py +0 -0
  17. qx_cli-1.1.0/src/qx/cli/scaffolds/query/__init__.py +0 -0
  18. qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/__init__.py +0 -0
  19. qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__init__.py +0 -0
  20. qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/__init__.py +0 -0
  21. qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__init__.py +0 -0
  22. qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__slice_name_snake__/__init__.py +0 -0
  23. qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__slice_name_snake__/queries/__init__.py +0 -0
  24. qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__slice_name_snake__/queries/__name_snake__.py.j2 +22 -0
  25. qx_cli-1.1.0/src/qx/cli/scaffolds/service/__init__.py +0 -0
  26. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/pyproject.toml.j2 +1 -1
  27. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +2 -1
  28. qx_cli-1.1.0/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +46 -0
  29. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/.env.example.j2 +16 -0
  30. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/Dockerfile.j2 +19 -0
  31. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/README.md.j2 +63 -0
  32. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/__init__.py +0 -0
  33. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic/__init__.py +0 -0
  34. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic/env.py.j2 +13 -0
  35. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic/script.py.mako +26 -0
  36. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic.ini.j2 +38 -0
  37. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/docker-compose.override.yaml.j2 +17 -0
  38. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/pyproject.toml.j2 +47 -0
  39. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__init__.py +0 -0
  40. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/__init__.py +0 -0
  41. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/__init__.py.j2 +3 -0
  42. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/__init__.py +0 -0
  43. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/commands/__init__.py +0 -0
  44. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/commands/__init__.py.j2 +1 -0
  45. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/queries/__init__.py +0 -0
  46. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/queries/__init__.py.j2 +1 -0
  47. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__init__.py +0 -0
  48. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/domain/__init__.py +0 -0
  49. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/domain/__init__.py.j2 +4 -0
  50. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/infrastructure/__init__.py +0 -0
  51. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/infrastructure/__init__.py.j2 +5 -0
  52. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/main.py.j2 +92 -0
  53. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/presentation/__domain_snake__.py.j2 +20 -0
  54. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/presentation/__init__.py +0 -0
  55. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/presentation/__init__.py.j2 +6 -0
  56. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/shared/__init__.py +0 -0
  57. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/shared/__init__.py.j2 +17 -0
  58. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/tests/__init__.py +0 -0
  59. qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/tests/test_smoke.py.j2 +46 -0
  60. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/__init__.py +0 -0
  61. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__init__.py +0 -0
  62. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/__init__.py +0 -0
  63. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__init__.py +0 -0
  64. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/__init__.py +0 -0
  65. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/__init__.py.j2 +1 -0
  66. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/commands/__init__.py +0 -0
  67. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/commands/__init__.py.j2 +1 -0
  68. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/queries/__init__.py +0 -0
  69. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/queries/__init__.py.j2 +1 -0
  70. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/presentation/__init__.py +0 -0
  71. qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/presentation/__slice_name_snake__.py.j2 +20 -0
  72. qx_cli-0.2.0/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +0 -33
  73. {qx_cli-0.2.0 → qx_cli-1.1.0}/.gitignore +0 -0
  74. {qx_cli-0.2.0 → qx_cli-1.1.0}/README.md +0 -0
  75. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/__init__.py +0 -0
  76. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/__init__.py +0 -0
  77. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/dev.py +0 -0
  78. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/py.typed +0 -0
  79. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/__init__.py +0 -0
  80. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/__init__.py +0 -0
  81. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/alembic/__init__.py +0 -0
  82. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/alembic/versions/__init__.py +0 -0
  83. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/alembic/versions/__migration_name__.py.j2 +0 -0
  84. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +0 -0
  85. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
  86. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +0 -0
  87. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/command/__init__.py +0 -0
  88. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +0 -0
  89. {qx_cli-0.2.0/src/qx/cli/scaffolds/endpoint → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs}/__init__.py +0 -0
  90. {qx_cli-0.2.0/src/qx/cli/scaffolds/event → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src}/__init__.py +0 -0
  91. {qx_cli-0.2.0/src/qx/cli/scaffolds/query → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__}/__init__.py +0 -0
  92. {qx_cli-0.2.0/src/qx/cli/scaffolds/service → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application}/__init__.py +0 -0
  93. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +0 -0
  94. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +0 -0
  95. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +0 -0
  96. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/.env.example.j2 +0 -0
  97. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/Dockerfile.j2 +0 -0
  98. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/README.md.j2 +0 -0
  99. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/alembic/env.py.j2 +0 -0
  100. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/alembic/script.py.mako +0 -0
  101. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/alembic.ini.j2 +0 -0
  102. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/docker-compose.override.yaml.j2 +0 -0
  103. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +0 -0
  104. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +0 -0
  105. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +0 -0
  106. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +0 -0
  107. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +0 -0
  108. {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/templates/__init__.py +0 -0
  109. {qx_cli-0.2.0 → qx_cli-1.1.0}/tests/test_cli.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qx-cli
3
- Version: 0.2.0
3
+ Version: 1.1.0
4
4
  Summary: Qx CLI: scaffolding, code generation, local dev orchestration
5
5
  Author: Qx Engineering
6
6
  License: MIT
7
7
  Requires-Python: >=3.14
8
8
  Requires-Dist: jinja2>=3.1.0
9
+ Requires-Dist: nats-py>=2.9.0
9
10
  Requires-Dist: pyyaml>=6.0
10
11
  Requires-Dist: qx-core
11
12
  Requires-Dist: rich>=13.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qx-cli"
3
- version = "0.2.0"
3
+ version = "1.1.0"
4
4
  description = "Qx CLI: scaffolding, code generation, local dev orchestration"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -11,6 +11,7 @@ dependencies = [
11
11
  "typer>=0.12.0",
12
12
  "rich>=13.0.0",
13
13
  "jinja2>=3.1.0",
14
+ "nats-py>=2.9.0",
14
15
  "pyyaml>=6.0",
15
16
  ]
16
17
 
@@ -0,0 +1,231 @@
1
+ """``qx dlq`` — inspect and replay dead-letter queue messages.
2
+
3
+ Commands::
4
+
5
+ qx dlq list # show recent dead letters
6
+ qx dlq replay <id> # re-publish a dead letter to its original NATS subject
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ app = typer.Typer(no_args_is_help=True)
19
+ console = Console()
20
+
21
+
22
+ def _get_db_url() -> str:
23
+ url = os.getenv("DATABASE_URL")
24
+ if not url:
25
+ console.print("[red]DATABASE_URL is not set.[/red]")
26
+ raise typer.Exit(code=1)
27
+ return url
28
+
29
+
30
+ def _get_nats_url() -> str:
31
+ return os.getenv("NATS_URL", "nats://localhost:4222")
32
+
33
+
34
+ @app.command("list")
35
+ def list_dead_letters(
36
+ db_url: str = typer.Option(
37
+ None,
38
+ "--db-url",
39
+ envvar="DATABASE_URL",
40
+ help="Postgres connection URL.",
41
+ ),
42
+ limit: int = typer.Option(50, "--limit", "-n", help="Maximum rows to display."),
43
+ event_name: str = typer.Option(
44
+ None,
45
+ "--event-name",
46
+ "-e",
47
+ help="Filter by event name (substring match).",
48
+ ),
49
+ ) -> None:
50
+ """List recent dead-letter messages."""
51
+
52
+ async def _run() -> None:
53
+ from sqlalchemy import text # noqa: PLC0415
54
+ from sqlalchemy.ext.asyncio import create_async_engine # noqa: PLC0415
55
+
56
+ url = (db_url or _get_db_url()).replace("postgresql://", "postgresql+asyncpg://", 1)
57
+ engine = create_async_engine(url, echo=False)
58
+
59
+ try:
60
+ async with engine.connect() as conn:
61
+ exists = (
62
+ await conn.execute(
63
+ text(
64
+ "SELECT 1 FROM information_schema.tables"
65
+ " WHERE table_name = 'qx_dead_letters'"
66
+ )
67
+ )
68
+ ).first()
69
+ if not exists:
70
+ console.print(
71
+ "[yellow]qx_dead_letters table not found."
72
+ " Run migrations or configure DeadLetterStore first.[/yellow]"
73
+ )
74
+ return
75
+
76
+ filters = ""
77
+ params: dict[str, object] = {"limit": limit}
78
+ if event_name:
79
+ filters = "WHERE event_name ILIKE :event_name"
80
+ params["event_name"] = f"%{event_name}%"
81
+
82
+ rows = (
83
+ (
84
+ await conn.execute(
85
+ text(
86
+ f"""
87
+ SELECT id, event_name, delivered_count,
88
+ last_error, failed_at, subject
89
+ FROM qx_dead_letters
90
+ {filters}
91
+ ORDER BY failed_at DESC
92
+ LIMIT :limit
93
+ """
94
+ ),
95
+ params,
96
+ )
97
+ )
98
+ .mappings()
99
+ .all()
100
+ )
101
+ finally:
102
+ await engine.dispose()
103
+
104
+ if not rows:
105
+ console.print("[dim]No dead letters found.[/dim]")
106
+ return
107
+
108
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
109
+ table.add_column("ID", style="dim")
110
+ table.add_column("Event Name")
111
+ table.add_column("Deliveries", justify="right")
112
+ table.add_column("Failed At")
113
+ table.add_column("Last Error")
114
+
115
+ for r in rows:
116
+ failed_str = r["failed_at"].strftime("%Y-%m-%d %H:%M:%S") if r["failed_at"] else "—"
117
+ error = (r["last_error"] or "")[:60]
118
+ if len(r["last_error"] or "") > 60:
119
+ error += "…"
120
+ table.add_row(
121
+ str(r["id"])[:8] + "…",
122
+ r["event_name"],
123
+ str(r["delivered_count"]),
124
+ failed_str,
125
+ error,
126
+ )
127
+
128
+ console.print(table)
129
+ console.print(f"\n[dim]{len(rows)} row(s) shown (limit {limit})[/dim]")
130
+
131
+ asyncio.run(_run())
132
+
133
+
134
+ @app.command()
135
+ def replay(
136
+ id: str = typer.Argument(..., help="UUID of the dead letter row to replay."),
137
+ db_url: str = typer.Option(
138
+ None,
139
+ "--db-url",
140
+ envvar="DATABASE_URL",
141
+ help="Postgres connection URL.",
142
+ ),
143
+ nats_url: str = typer.Option(
144
+ None,
145
+ "--nats-url",
146
+ envvar="NATS_URL",
147
+ help="NATS server URL (falls back to NATS_URL or nats://localhost:4222).",
148
+ ),
149
+ subject_override: str = typer.Option(
150
+ None,
151
+ "--subject",
152
+ help="Override the NATS subject (default: use the original subject from the DB row).",
153
+ ),
154
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
155
+ ) -> None:
156
+ """Re-publish a dead letter to its original NATS subject.
157
+
158
+ Fetches the stored payload from ``qx_dead_letters`` by ID and
159
+ republishes it to the original NATS subject so the worker can
160
+ attempt processing again.
161
+ """
162
+
163
+ async def _run() -> None:
164
+ import json # noqa: PLC0415
165
+
166
+ import nats # noqa: PLC0415
167
+ from sqlalchemy import text # noqa: PLC0415
168
+ from sqlalchemy.ext.asyncio import create_async_engine # noqa: PLC0415
169
+
170
+ url = (db_url or _get_db_url()).replace("postgresql://", "postgresql+asyncpg://", 1)
171
+ engine = create_async_engine(url, echo=False)
172
+
173
+ try:
174
+ async with engine.connect() as conn:
175
+ row = (
176
+ (
177
+ await conn.execute(
178
+ text(
179
+ "SELECT id, event_name, payload, headers, subject"
180
+ " FROM qx_dead_letters WHERE id = :id"
181
+ ),
182
+ {"id": id},
183
+ )
184
+ )
185
+ .mappings()
186
+ .first()
187
+ )
188
+ finally:
189
+ await engine.dispose()
190
+
191
+ if row is None:
192
+ console.print(f"[red]Dead letter [bold]{id}[/bold] not found.[/red]")
193
+ raise typer.Exit(code=1)
194
+
195
+ target_subject = subject_override or row["subject"]
196
+ if not target_subject:
197
+ console.print("[red]Row has no stored subject. Use --subject to specify one.[/red]")
198
+ raise typer.Exit(code=1)
199
+
200
+ if not yes:
201
+ confirmed = typer.confirm(
202
+ f"Replay [bold]{row['event_name']}[/bold] → [bold]{target_subject}[/bold]?"
203
+ )
204
+ if not confirmed:
205
+ raise typer.Abort()
206
+
207
+ payload_bytes = (
208
+ row["payload"].encode()
209
+ if isinstance(row["payload"], str)
210
+ else json.dumps(row["payload"]).encode()
211
+ )
212
+
213
+ # Reconstruct headers from stored headers dict, dropping None values.
214
+ stored_headers: dict[str, str] = {}
215
+ if row["headers"]:
216
+ raw = row["headers"] if isinstance(row["headers"], dict) else json.loads(row["headers"])
217
+ stored_headers = {k: v for k, v in raw.items() if v is not None}
218
+
219
+ nc = await nats.connect(nats_url or _get_nats_url())
220
+ try:
221
+ js = nc.jetstream()
222
+ await js.publish(target_subject, payload_bytes, headers=stored_headers or None)
223
+ finally:
224
+ await nc.drain()
225
+
226
+ console.print(
227
+ f"[green]✓[/green] Replayed [bold]{row['event_name']}[/bold]"
228
+ f" → [bold]{target_subject}[/bold]"
229
+ )
230
+
231
+ asyncio.run(_run())
@@ -178,10 +178,45 @@ def _check_connectivity() -> None:
178
178
  )
179
179
 
180
180
 
181
+ _MIN_VERSIONS: dict[str, str] = {
182
+ "qx-core": "0.2.0",
183
+ "qx-cqrs": "0.2.0",
184
+ "qx-db": "0.2.0",
185
+ "qx-http": "0.2.0",
186
+ "qx-events": "0.2.0",
187
+ "qx-worker": "0.2.0",
188
+ "qx-cli": "0.2.0",
189
+ }
190
+
191
+
192
+ def _check_version_mismatches() -> None:
193
+ from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415
194
+
195
+ from packaging.version import Version # noqa: PLC0415
196
+
197
+ console.print("\n[bold]Version requirements[/bold]")
198
+ found_mismatch = False
199
+ for pkg, min_ver in _MIN_VERSIONS.items():
200
+ try:
201
+ installed = version(pkg)
202
+ if Version(installed) < Version(min_ver):
203
+ console.print(_fail(f"{pkg} {installed} < {min_ver} (minimum required)"))
204
+ found_mismatch = True
205
+ else:
206
+ console.print(_ok(f"{pkg} {installed} >= {min_ver}"))
207
+ except PackageNotFoundError:
208
+ pass # already reported in _check_packages
209
+
210
+ if not found_mismatch:
211
+ console.print(_ok("All installed qx packages meet minimum version requirements"))
212
+
213
+
181
214
  @app.callback(invoke_without_command=True)
182
215
  def doctor(
183
- connectivity: bool = typer.Option(
184
- False, "--connectivity", "-c", help="Also probe Postgres / Redis / NATS TCP ports."
216
+ no_connectivity: bool = typer.Option(
217
+ False,
218
+ "--no-connectivity",
219
+ help="Skip Postgres / Redis / NATS TCP reachability probes.",
185
220
  ),
186
221
  fix: bool = typer.Option(
187
222
  False, "--fix", help="Print shell commands to resolve detected issues."
@@ -196,8 +231,9 @@ def doctor(
196
231
  _check_tools(failures, fix_commands)
197
232
  _check_packages()
198
233
  _check_env()
234
+ _check_version_mismatches()
199
235
 
200
- if connectivity:
236
+ if not no_connectivity:
201
237
  _check_connectivity()
202
238
 
203
239
  console.print()
@@ -3,6 +3,12 @@
3
3
  Each subcommand expects the cwd to be a qx service directory
4
4
  (containing a ``pyproject.toml`` and a Python package matching the service
5
5
  name). The CLI infers the service package from ``pyproject.toml``.
6
+
7
+ Vertical-slice services support a ``<slice>/<Name>`` syntax:
8
+
9
+ qx generate command user/CreateUser # → user/commands/create_user.py
10
+ qx generate query user/GetUser # → user/queries/get_user.py
11
+ qx generate slice payment # → payment/ skeleton
6
12
  """
7
13
 
8
14
  from __future__ import annotations
@@ -33,7 +39,6 @@ def _service_package(start: Path | None = None) -> tuple[Path, str]:
33
39
  if not name:
34
40
  continue
35
41
  pkg = name.replace("-", "_")
36
- # Validate the package directory actually exists
37
42
  if (candidate / "src" / pkg).exists():
38
43
  return candidate, pkg
39
44
  if (candidate / pkg).exists():
@@ -68,46 +73,79 @@ def aggregate(
68
73
 
69
74
  @app.command()
70
75
  def command(
71
- name: str = typer.Argument(..., help="Command name (e.g. 'CreateUser')."),
76
+ name: str = typer.Argument(
77
+ ..., help="Command name. Use 'slice/Name' for vertical-slice services."
78
+ ),
72
79
  aggregate_for: str = typer.Option(
73
80
  "",
74
81
  "--aggregate",
75
82
  "-a",
76
- help="Target aggregate (for layout hints).",
83
+ help="Target aggregate (for layout hints, layered mode only).",
77
84
  ),
78
85
  force: bool = typer.Option(False, "--force", "-f"),
79
86
  ) -> None:
80
- """Generate a Command class and its handler."""
87
+ """Generate a Command class and its handler.
88
+
89
+ Layered: qx generate command CreateUser
90
+ VS: qx generate command user/CreateUser
91
+ """
81
92
  root, pkg = _service_package()
82
- context = _names(name, pkg)
83
- context["aggregate"] = _names(aggregate_for, pkg) if aggregate_for else None
84
- files = render_tree(
85
- "qx.cli.scaffolds",
86
- "command",
87
- root,
88
- context,
89
- overwrite=force,
90
- )
93
+ if "/" in name:
94
+ slice_name, artifact_name = _parse_slice(name)
95
+ context = _names(artifact_name, pkg)
96
+ context.update(_slice_names(slice_name))
97
+ files = render_tree("qx.cli.scaffolds", "command_vs", root, context, overwrite=force)
98
+ else:
99
+ context = _names(name, pkg)
100
+ context["aggregate"] = _names(aggregate_for, pkg) if aggregate_for else None
101
+ files = render_tree("qx.cli.scaffolds", "command", root, context, overwrite=force)
91
102
  console.rule(f"[bold green]command {context['name_pascal']} generated[/bold green]")
92
103
  preview_tree(files, root)
93
104
 
94
105
 
95
106
  @app.command()
96
107
  def query(
97
- name: str = typer.Argument(..., help="Query name (e.g. 'GetUser')."),
108
+ name: str = typer.Argument(
109
+ ..., help="Query name. Use 'slice/Name' for vertical-slice services."
110
+ ),
111
+ force: bool = typer.Option(False, "--force", "-f"),
112
+ ) -> None:
113
+ """Generate a Query class and its handler.
114
+
115
+ Layered: qx generate query GetUser
116
+ VS: qx generate query user/GetUser
117
+ """
118
+ root, pkg = _service_package()
119
+ if "/" in name:
120
+ slice_name, artifact_name = _parse_slice(name)
121
+ context = _names(artifact_name, pkg)
122
+ context.update(_slice_names(slice_name))
123
+ files = render_tree("qx.cli.scaffolds", "query_vs", root, context, overwrite=force)
124
+ else:
125
+ context = _names(name, pkg)
126
+ files = render_tree("qx.cli.scaffolds", "query", root, context, overwrite=force)
127
+ console.rule(f"[bold green]query {context['name_pascal']} generated[/bold green]")
128
+ preview_tree(files, root)
129
+
130
+
131
+ @app.command()
132
+ def esaggregate(
133
+ name: str = typer.Argument(..., help="Aggregate name (PascalCase, e.g. 'Shipment')."),
98
134
  force: bool = typer.Option(False, "--force", "-f"),
99
135
  ) -> None:
100
- """Generate a Query class and its handler."""
136
+ """Generate an event-sourced aggregate (EventSourcedAggregate subclass, no ORM mapping)."""
101
137
  root, pkg = _service_package()
102
138
  context = _names(name, pkg)
103
139
  files = render_tree(
104
140
  "qx.cli.scaffolds",
105
- "query",
141
+ "esaggregate",
106
142
  root,
107
143
  context,
108
144
  overwrite=force,
109
145
  )
110
- console.rule(f"[bold green]query {context['name_pascal']} generated[/bold green]")
146
+ console.rule(
147
+ f"[bold green]event-sourced aggregate {context['name_pascal']} generated[/bold green]"
148
+ )
111
149
  preview_tree(files, root)
112
150
 
113
151
 
@@ -158,19 +196,67 @@ def endpoint(
158
196
  preview_tree(files, root)
159
197
 
160
198
 
199
+ @app.command(name="slice")
200
+ def generate_slice(
201
+ name: str = typer.Argument(..., help="Slice name (e.g. 'payment', 'user')."),
202
+ force: bool = typer.Option(False, "--force", "-f"),
203
+ ) -> None:
204
+ """Generate a new vertical slice (application/<slice>/commands/, queries/, presentation/<slice>.py).
205
+
206
+ After generating, mount the new router in main.py:
207
+
208
+ from <service>.presentation.payment import router as payment_router
209
+ app.include_router(payment_router, prefix="/v1")
210
+ """
211
+ root, pkg = _service_package()
212
+ context = _slice_names(name)
213
+ context["service_pkg"] = pkg
214
+ files = render_tree("qx.cli.scaffolds", "slice", root, context, overwrite=force)
215
+ console.rule(f"[bold green]slice {context['slice_name_pascal']} generated[/bold green]")
216
+ preview_tree(files, root)
217
+ console.print(
218
+ "\n[bold]Wire the router in main.py:[/bold]\n"
219
+ f" from {pkg}.presentation.{context['slice_name_snake']} import router as "
220
+ f"{context['slice_name_snake']}_router\n"
221
+ f' app.include_router({context["slice_name_snake"]}_router, prefix="/v1")\n'
222
+ )
223
+
224
+
161
225
  # ---- helpers ----
162
226
 
163
227
 
164
228
  def _names(name: str, pkg: str) -> dict[str, Any]:
165
229
  """Build the standard naming variants for a generated artifact."""
230
+ snake = _snake(name)
166
231
  return {
167
232
  "name_pascal": _pascal(name),
168
- "name_snake": _snake(name),
233
+ "name_snake": snake,
169
234
  "name_kebab": _kebab(name),
235
+ "name_upper": snake.upper(),
170
236
  "service_pkg": pkg,
171
237
  }
172
238
 
173
239
 
240
+ def _slice_names(slice_name: str) -> dict[str, Any]:
241
+ """Build naming variants for a slice."""
242
+ snake = _snake(slice_name)
243
+ return {
244
+ "slice_name_snake": snake,
245
+ "slice_name_pascal": _pascal(slice_name),
246
+ "slice_name_kebab": _kebab(slice_name),
247
+ }
248
+
249
+
250
+ def _parse_slice(name: str) -> tuple[str, str]:
251
+ """Split 'user/CreateUser' → ('user', 'CreateUser')."""
252
+ parts = name.split("/", 1)
253
+ if len(parts) != 2 or not parts[0] or not parts[1]:
254
+ raise typer.BadParameter(
255
+ f"Invalid format '{name}'. Use '<slice>/<Name>' (e.g. 'user/CreateUser')."
256
+ )
257
+ return parts[0], parts[1]
258
+
259
+
174
260
  def _snake(s: str) -> str:
175
261
  import re # noqa: PLC0415
176
262
 
@@ -22,11 +22,23 @@ def service(
22
22
  help="Directory to create the project in.",
23
23
  ),
24
24
  force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files."),
25
+ slices: bool = typer.Option(
26
+ False,
27
+ "--slices/--no-slices",
28
+ help="Use vertical-slice layout (feature-first) instead of layered.",
29
+ ),
30
+ domain: str = typer.Option(
31
+ "",
32
+ "--domain",
33
+ "-d",
34
+ help="Starter slice name for --slices (defaults to service name).",
35
+ ),
25
36
  ) -> None:
26
37
  """Scaffold a new qx service.
27
38
 
28
- Generates a fully wired service with application/domain/infrastructure/
29
- presentation layers, a Dockerfile, alembic config, and a passing test.
39
+ By default generates a layered service (application/domain/infrastructure/
40
+ presentation). With --slices, generates a vertical-slice layout where each
41
+ feature is self-contained under its own directory.
30
42
  """
31
43
  pkg_name = _snake(name)
32
44
  context = {
@@ -36,6 +48,16 @@ def service(
36
48
  "service_kebab": _kebab(name),
37
49
  "service_pkg_path": pkg_name.replace("_", "/"),
38
50
  }
51
+
52
+ if slices:
53
+ domain_snake = _snake(domain) if domain else pkg_name
54
+ context["domain_snake"] = domain_snake
55
+ context["domain_pascal"] = _pascal(domain_snake)
56
+ context["domain_kebab"] = _kebab(domain_snake)
57
+ template = "service_vs"
58
+ else:
59
+ template = "service"
60
+
39
61
  dest = target / context["service_kebab"]
40
62
  if dest.exists() and not force and any(dest.iterdir()):
41
63
  console.print(f"[red]error[/red] {dest} exists and is not empty (use --force to overwrite)")
@@ -44,21 +66,30 @@ def service(
44
66
 
45
67
  files = render_tree(
46
68
  "qx.cli.scaffolds",
47
- "service",
69
+ template,
48
70
  dest,
49
71
  context,
50
72
  overwrite=force,
51
73
  )
52
74
  console.rule("[bold green]Service scaffolded[/bold green]")
53
75
  preview_tree(files, dest)
54
- console.print(
76
+
77
+ next_steps = (
55
78
  "\n[bold]Next steps:[/bold]\n"
56
79
  f" cd {context['service_kebab']}\n"
57
80
  " uv sync\n"
58
81
  " qx dev up # start Postgres · Redis · NATS · Grafana\n"
59
82
  " uv run alembic upgrade head\n"
60
- " uv run uvicorn " + f"{pkg_name}.main:app --reload\n"
83
+ f" uv run uvicorn {pkg_name}.main:app --reload\n"
61
84
  )
85
+ if slices:
86
+ next_steps += (
87
+ "\n[bold]Add more slices:[/bold]\n"
88
+ " qx generate slice <slice_name>\n"
89
+ " qx generate command <slice_name>/<CommandName>\n"
90
+ " qx generate query <slice_name>/<QueryName>\n"
91
+ )
92
+ console.print(next_steps)
62
93
 
63
94
 
64
95
  def _snake(s: str) -> str: