csrd-utils 0.3.13__tar.gz → 0.3.14__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 (57) hide show
  1. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/PKG-INFO +30 -16
  2. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/README.md +29 -15
  3. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/pyproject.toml +1 -1
  4. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/__main__.py +371 -1
  5. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/.gitignore +0 -0
  6. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/__init__.py +0 -0
  7. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/augmentor.py +0 -0
  8. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/cluster.py +0 -0
  9. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/doctor.py +0 -0
  10. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  11. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  12. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  13. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  14. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  15. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  16. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  17. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  18. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  19. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/fragments.py +0 -0
  20. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/generator.py +0 -0
  21. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/resources.py +0 -0
  22. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  23. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  24. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  25. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  26. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
  27. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  28. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
  29. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
  30. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  31. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +0 -0
  32. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
  33. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
  34. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
  35. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
  36. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
  37. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
  38. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
  39. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
  40. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
  41. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
  42. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
  43. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +0 -0
  44. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
  45. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -0
  46. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
  47. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -0
  48. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
  49. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
  50. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  51. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/acceptance/test_logging_middleware.py +0 -0
  52. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
  53. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +0 -0
  54. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_cache.py +0 -0
  55. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_database_settings.py +0 -0
  56. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_delegates.py +0 -0
  57. {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_worker_broker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csrd-utils
3
- Version: 0.3.13
3
+ Version: 0.3.14
4
4
  Summary: CLI utilities for csrd service generation and feature augmentation
5
5
  Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
6
  Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/utils
@@ -26,11 +26,13 @@ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#sub
26
26
  ```bash
27
27
  csrd --help
28
28
  csrd --version
29
- csrd new service # compact interactive mode (prompts 1-6, then advanced gate)
29
+ csrd new workspace # create workspace, then choose none/service/cluster
30
+ csrd new service # requires workspace; prompts to create one if missing
30
31
  csrd new service --interactive --name items # pre-fills name prompt with 'items'
31
- csrd new service --name inventory --no-interactive --output .
32
- csrd new cluster --spec ./cluster.yaml --output .
33
- csrd new cluster # interactive cluster builder (preset + service prompts)
32
+ csrd new service --name inventory --no-interactive --output ./my-workspace
33
+ csrd new service --name pricing --output ./my-workspace --no-interactive
34
+ csrd new cluster --spec ./cluster.yaml --output ./my-workspace
35
+ csrd new cluster # requires workspace; interactive mode can offer to create one
34
36
  csrd feature list
35
37
  csrd feature plan workers --service .
36
38
  csrd feature plan workers --service . --json
@@ -42,7 +44,14 @@ csrd doctor --service .
42
44
 
43
45
  ### Interactive generation behavior
44
46
 
47
+ - `csrd new service` and `csrd new cluster` are workspace-first commands.
48
+ - If a workspace marker is missing in the selected output path, interactive flows ask to create one and continue.
49
+ - Non-interactive generation errors when no workspace marker is present.
50
+ - Workspace cluster source of truth is persisted at `.csrd/cluster.yaml`.
51
+ - If `.csrd/cluster.yaml` already exists, `csrd new service` appends the generated service and refreshes scaffold files (`docker-compose.yml`, `.env.example`, `README.md`, `smoke.sh`). Service source directories and running containers are never touched.
52
+ - If `.csrd/cluster.yaml` is missing and a second service is added to the workspace, `csrd new service` bootstraps `.csrd/cluster.yaml` and writes initial cluster scaffold files.
45
53
  - `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
54
+ - `csrd new workspace` creates a workspace directory and prompts whether to generate `none`, `service`, or `cluster` immediately.
46
55
  - In compact mode, advanced options are hidden by default.
47
56
  - `csrd new service --interactive` runs the full verbose interactive flow.
48
57
  - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
@@ -54,20 +63,24 @@ csrd doctor --service .
54
63
  ## Typical workflow
55
64
 
56
65
  ```bash
57
- # 0) Generate a new service from bundled template
58
- csrd new service --name inventory --output . --database postgres --workers
66
+ # 0) Create a workspace and generate a service inside it
67
+ csrd new workspace --name local-dev --output . --generate none
68
+ csrd new service --name inventory --output ./local-dev --database postgres --workers
59
69
 
60
70
  # 1) Verify service compatibility
61
- csrd doctor --service ./inventory
71
+ csrd doctor --service ./local-dev/inventory
62
72
 
63
73
  # 2) Inspect available bundled features
64
74
  csrd feature list
65
75
 
66
76
  # 3) Dry-run a feature
67
- csrd feature plan workers --service ./inventory
77
+ csrd feature plan workers --service ./local-dev/inventory
68
78
 
69
79
  # 4) Apply feature files/merges
70
- csrd feature add workers --service ./inventory
80
+ csrd feature add workers --service ./local-dev/inventory
81
+
82
+ # 5) Add a second service — workspace auto-upgrades to cluster
83
+ csrd new service --name pricing --output ./local-dev --database postgres
71
84
  ```
72
85
 
73
86
  ## Bundled assets
@@ -102,6 +115,8 @@ If an agent can only see the installed CLI (not source files), use this explorat
102
115
  ```bash
103
116
  csrd --help
104
117
  csrd new --help
118
+ csrd new workspace --help
119
+ csrd new service --help
105
120
  csrd new cluster --help
106
121
  csrd feature --help
107
122
  csrd doctor --help
@@ -112,10 +127,9 @@ Then run a clean smoke flow:
112
127
 
113
128
  ```bash
114
129
  mkdir -p /tmp/csrd-smoke
115
- cd /tmp/csrd-smoke
116
- csrd new service --name demo-svc --output . --no-interactive
117
- cd demo-svc
118
- csrd doctor
119
- csrd feature plan workers
120
- csrd feature add workers
130
+ csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
131
+ csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
132
+ csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
133
+ csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
134
+ csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
121
135
  ```
@@ -13,11 +13,13 @@ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#sub
13
13
  ```bash
14
14
  csrd --help
15
15
  csrd --version
16
- csrd new service # compact interactive mode (prompts 1-6, then advanced gate)
16
+ csrd new workspace # create workspace, then choose none/service/cluster
17
+ csrd new service # requires workspace; prompts to create one if missing
17
18
  csrd new service --interactive --name items # pre-fills name prompt with 'items'
18
- csrd new service --name inventory --no-interactive --output .
19
- csrd new cluster --spec ./cluster.yaml --output .
20
- csrd new cluster # interactive cluster builder (preset + service prompts)
19
+ csrd new service --name inventory --no-interactive --output ./my-workspace
20
+ csrd new service --name pricing --output ./my-workspace --no-interactive
21
+ csrd new cluster --spec ./cluster.yaml --output ./my-workspace
22
+ csrd new cluster # requires workspace; interactive mode can offer to create one
21
23
  csrd feature list
22
24
  csrd feature plan workers --service .
23
25
  csrd feature plan workers --service . --json
@@ -29,7 +31,14 @@ csrd doctor --service .
29
31
 
30
32
  ### Interactive generation behavior
31
33
 
34
+ - `csrd new service` and `csrd new cluster` are workspace-first commands.
35
+ - If a workspace marker is missing in the selected output path, interactive flows ask to create one and continue.
36
+ - Non-interactive generation errors when no workspace marker is present.
37
+ - Workspace cluster source of truth is persisted at `.csrd/cluster.yaml`.
38
+ - If `.csrd/cluster.yaml` already exists, `csrd new service` appends the generated service and refreshes scaffold files (`docker-compose.yml`, `.env.example`, `README.md`, `smoke.sh`). Service source directories and running containers are never touched.
39
+ - If `.csrd/cluster.yaml` is missing and a second service is added to the workspace, `csrd new service` bootstraps `.csrd/cluster.yaml` and writes initial cluster scaffold files.
32
40
  - `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
41
+ - `csrd new workspace` creates a workspace directory and prompts whether to generate `none`, `service`, or `cluster` immediately.
33
42
  - In compact mode, advanced options are hidden by default.
34
43
  - `csrd new service --interactive` runs the full verbose interactive flow.
35
44
  - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
@@ -41,20 +50,24 @@ csrd doctor --service .
41
50
  ## Typical workflow
42
51
 
43
52
  ```bash
44
- # 0) Generate a new service from bundled template
45
- csrd new service --name inventory --output . --database postgres --workers
53
+ # 0) Create a workspace and generate a service inside it
54
+ csrd new workspace --name local-dev --output . --generate none
55
+ csrd new service --name inventory --output ./local-dev --database postgres --workers
46
56
 
47
57
  # 1) Verify service compatibility
48
- csrd doctor --service ./inventory
58
+ csrd doctor --service ./local-dev/inventory
49
59
 
50
60
  # 2) Inspect available bundled features
51
61
  csrd feature list
52
62
 
53
63
  # 3) Dry-run a feature
54
- csrd feature plan workers --service ./inventory
64
+ csrd feature plan workers --service ./local-dev/inventory
55
65
 
56
66
  # 4) Apply feature files/merges
57
- csrd feature add workers --service ./inventory
67
+ csrd feature add workers --service ./local-dev/inventory
68
+
69
+ # 5) Add a second service — workspace auto-upgrades to cluster
70
+ csrd new service --name pricing --output ./local-dev --database postgres
58
71
  ```
59
72
 
60
73
  ## Bundled assets
@@ -89,6 +102,8 @@ If an agent can only see the installed CLI (not source files), use this explorat
89
102
  ```bash
90
103
  csrd --help
91
104
  csrd new --help
105
+ csrd new workspace --help
106
+ csrd new service --help
92
107
  csrd new cluster --help
93
108
  csrd feature --help
94
109
  csrd doctor --help
@@ -99,10 +114,9 @@ Then run a clean smoke flow:
99
114
 
100
115
  ```bash
101
116
  mkdir -p /tmp/csrd-smoke
102
- cd /tmp/csrd-smoke
103
- csrd new service --name demo-svc --output . --no-interactive
104
- cd demo-svc
105
- csrd doctor
106
- csrd feature plan workers
107
- csrd feature add workers
117
+ csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
118
+ csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
119
+ csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
120
+ csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
121
+ csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
108
122
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.13"
3
+ version = "0.3.14"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -8,9 +8,19 @@ from contextlib import suppress
8
8
  from importlib.metadata import version
9
9
  from pathlib import Path
10
10
  from typing import TypedDict
11
+ from urllib.parse import urlparse
12
+
13
+ import yaml # type: ignore[import-untyped]
11
14
 
12
15
  from .augmentor import ServiceAugmentor
13
- from .cluster import PRESETS, ClusterSpec, ServiceSpec, generate_cluster, load_cluster_spec
16
+ from .cluster import (
17
+ PRESETS,
18
+ ClusterSpec,
19
+ ServiceSpec,
20
+ generate_cluster,
21
+ load_cluster_spec,
22
+ normalize_cluster_spec,
23
+ )
14
24
  from .doctor import run_doctor
15
25
  from .generator import generate_service
16
26
  from .resources import features_path
@@ -41,6 +51,17 @@ class GenerateClusterArgs(TypedDict):
41
51
  overwrite_if_exists: bool
42
52
 
43
53
 
54
+ class GenerateWorkspaceArgs(TypedDict):
55
+ name: str
56
+ output_dir: Path
57
+ overwrite_if_exists: bool
58
+ generate: str
59
+
60
+
61
+ WORKSPACE_MARKER = ".csrd-workspace"
62
+ WORKSPACE_CLUSTER_SPEC = Path(".csrd") / "cluster.yaml"
63
+
64
+
44
65
  def _prompt_text(label: str, default: str) -> str:
45
66
  value = input(f"{label} [{default}]: ").strip()
46
67
  return value or default
@@ -263,6 +284,298 @@ def _collect_new_cluster_config(args: argparse.Namespace) -> GenerateClusterArgs
263
284
  )
264
285
 
265
286
 
287
+ def _collect_new_workspace_config(args: argparse.Namespace) -> GenerateWorkspaceArgs:
288
+ name = args.name or _prompt_text("Workspace name", "my-workspace")
289
+ output_dir = (
290
+ args.output
291
+ if args.name is not None
292
+ else Path(_prompt_text("Output directory", str(args.output)))
293
+ )
294
+ generate = args.generate or _prompt_choice(
295
+ "Generate in workspace now", ["none", "service", "cluster"], "none"
296
+ )
297
+ return GenerateWorkspaceArgs(
298
+ {
299
+ "name": name,
300
+ "output_dir": output_dir,
301
+ "overwrite_if_exists": args.force,
302
+ "generate": generate,
303
+ }
304
+ )
305
+
306
+
307
+ def _create_workspace(config: GenerateWorkspaceArgs) -> Path:
308
+ workspace_root = config["output_dir"].resolve() / config["name"]
309
+ if workspace_root.exists() and not workspace_root.is_dir():
310
+ raise FileExistsError(f"Target path exists and is not a directory: {workspace_root}")
311
+
312
+ workspace_root.mkdir(parents=True, exist_ok=True)
313
+ if any(workspace_root.iterdir()) and not config["overwrite_if_exists"]:
314
+ raise FileExistsError(
315
+ f"Target directory already exists and is not empty: {workspace_root}. Use --force to overwrite."
316
+ )
317
+
318
+ _write_workspace_marker(workspace_root)
319
+ return workspace_root
320
+
321
+
322
+ def _default_service_namespace(output_dir: Path) -> argparse.Namespace:
323
+ return argparse.Namespace(
324
+ interactive=True,
325
+ name=None,
326
+ output=output_dir,
327
+ description="A microservice using the csrd library stack",
328
+ port=8080,
329
+ model_name="Item",
330
+ model_name_plural="items",
331
+ database="none",
332
+ delegates=False,
333
+ upstreams="",
334
+ workers=False,
335
+ broker="redis",
336
+ caching=False,
337
+ cache_backend="redis",
338
+ structured_logging=False,
339
+ force=False,
340
+ )
341
+
342
+
343
+ def _default_cluster_namespace(output_dir: Path) -> argparse.Namespace:
344
+ return argparse.Namespace(
345
+ spec=None,
346
+ name=None,
347
+ output=output_dir,
348
+ preset="none",
349
+ force=False,
350
+ )
351
+
352
+
353
+ def _write_workspace_marker(path: Path) -> None:
354
+ (path / WORKSPACE_MARKER).write_text("workspace: csrd\n", encoding="utf-8")
355
+
356
+
357
+ def _is_workspace(path: Path) -> bool:
358
+ return (path / WORKSPACE_MARKER).is_file()
359
+
360
+
361
+ def _service_is_interactive(args: argparse.Namespace) -> bool:
362
+ explicit_interactive = getattr(args, "interactive", None)
363
+ return bool(explicit_interactive) if explicit_interactive is not None else args.name is None
364
+
365
+
366
+ def _ensure_workspace_for_output(output_dir: Path, allow_prompt: bool) -> Path:
367
+ workspace_root = output_dir.resolve()
368
+ if _is_workspace(workspace_root):
369
+ return workspace_root
370
+
371
+ if workspace_root.exists() and not workspace_root.is_dir():
372
+ raise ValueError(f"Output path is not a directory: {workspace_root}")
373
+
374
+ if allow_prompt:
375
+ create_workspace = _prompt_bool(
376
+ f"No workspace found at {workspace_root}. Create workspace marker here", True
377
+ )
378
+ if create_workspace:
379
+ workspace_root.mkdir(parents=True, exist_ok=True)
380
+ _write_workspace_marker(workspace_root)
381
+ print(f"Generated workspace at: {workspace_root}")
382
+ return workspace_root
383
+
384
+ raise ValueError(
385
+ "This command requires a csrd workspace. Run 'csrd new workspace' first or use a workspace output path."
386
+ )
387
+
388
+
389
+ def _workspace_cluster_spec_path(workspace_root: Path) -> Path:
390
+ return workspace_root / WORKSPACE_CLUSTER_SPEC
391
+
392
+
393
+ def _load_workspace_cluster_spec(workspace_root: Path) -> ClusterSpec:
394
+ spec_path = _workspace_cluster_spec_path(workspace_root)
395
+ if spec_path.is_file():
396
+ return load_cluster_spec(spec_path)
397
+ return {
398
+ "cluster": {"name": workspace_root.name, "preset": "none"},
399
+ "services": [],
400
+ }
401
+
402
+
403
+ def _save_workspace_cluster_spec(workspace_root: Path, spec: ClusterSpec) -> None:
404
+ spec_path = _workspace_cluster_spec_path(workspace_root)
405
+ spec_path.parent.mkdir(parents=True, exist_ok=True)
406
+ normalized = normalize_cluster_spec(spec)
407
+ spec_path.write_text(yaml.safe_dump(normalized, sort_keys=False), encoding="utf-8")
408
+
409
+
410
+ def _delegate_targets_from_upstreams(raw_upstreams: str) -> list[str]:
411
+ targets: list[str] = []
412
+ for raw in [entry.strip() for entry in raw_upstreams.split(",") if entry.strip()]:
413
+ parsed = urlparse(raw)
414
+ if not parsed.hostname:
415
+ raise ValueError(
416
+ f"Unable to infer delegate target from upstream URL: {raw}. Use full URLs with hostnames."
417
+ )
418
+ targets.append(parsed.hostname)
419
+ return targets
420
+
421
+
422
+ def _service_config_to_cluster_service(config: GenerateServiceArgs) -> ServiceSpec:
423
+ has_database = config["has_database"]
424
+ has_workers = config["has_workers"]
425
+ has_delegates = config["has_delegates"]
426
+ selected_roles = sum(1 for enabled in (has_database, has_workers, has_delegates) if enabled)
427
+ if selected_roles > 1:
428
+ raise ValueError(
429
+ "Workspace cluster mapping supports one core role per generated service (database or workers or delegates)."
430
+ )
431
+
432
+ service: ServiceSpec = {
433
+ "name": config["name"],
434
+ "port": config["port"],
435
+ "features": [],
436
+ "requires": [],
437
+ }
438
+
439
+ if has_database:
440
+ service["type"] = "db"
441
+ service["database"] = config["database"]
442
+ elif has_workers:
443
+ service["type"] = "worker"
444
+ service["broker"] = config["broker"]
445
+ service["requires"] = ["redis" if config["broker"] == "redis" else "rabbitmq"]
446
+ elif has_delegates:
447
+ targets = _delegate_targets_from_upstreams(config["upstreams"])
448
+ service["type"] = "delegate"
449
+ service["delegates"] = targets
450
+ service["requires"] = list(targets)
451
+ else:
452
+ service["type"] = "platform"
453
+ service["role"] = "custom"
454
+
455
+ if config["has_caching"]:
456
+ service["features"].append("caching")
457
+ if config["has_structured_logging"]:
458
+ service["features"].append("logging")
459
+
460
+ return service
461
+
462
+
463
+ def _append_service_to_workspace_cluster(
464
+ workspace_root: Path, config: GenerateServiceArgs
465
+ ) -> ClusterSpec:
466
+ spec = _load_workspace_cluster_spec(workspace_root)
467
+ service_names = {str(service.get("name", "")).strip() for service in spec.get("services", [])}
468
+ if config["name"] in service_names:
469
+ raise ValueError(f"Service '{config['name']}' already exists in workspace cluster spec")
470
+
471
+ spec["services"].append(_service_config_to_cluster_service(config))
472
+ normalized = normalize_cluster_spec(spec)
473
+ _save_workspace_cluster_spec(workspace_root, normalized)
474
+ return normalized
475
+
476
+
477
+ def _workspace_service_roots(workspace_root: Path) -> list[Path]:
478
+ roots: list[Path] = []
479
+ for child in workspace_root.iterdir():
480
+ if not child.is_dir() or child.name.startswith("."):
481
+ continue
482
+ if (
483
+ (child / "src" / "app").is_dir()
484
+ and (child / "tests").is_dir()
485
+ and (child / "requirements.txt").is_file()
486
+ and (child / "docker-compose.yml").is_file()
487
+ ):
488
+ roots.append(child)
489
+ return sorted(roots)
490
+
491
+
492
+ def _infer_port_from_service_root(service_root: Path) -> int:
493
+ try:
494
+ payload = yaml.safe_load((service_root / "docker-compose.yml").read_text(encoding="utf-8"))
495
+ except (OSError, yaml.YAMLError):
496
+ return 8080
497
+
498
+ if not isinstance(payload, dict):
499
+ return 8080
500
+ services = payload.get("services")
501
+ if not isinstance(services, dict) or not services:
502
+ return 8080
503
+
504
+ first_service = next(iter(services.values()))
505
+ if not isinstance(first_service, dict):
506
+ return 8080
507
+ ports = first_service.get("ports")
508
+ if not isinstance(ports, list) or not ports:
509
+ return 8080
510
+
511
+ first_port = ports[0]
512
+ if isinstance(first_port, int):
513
+ return first_port
514
+ if isinstance(first_port, str):
515
+ host_port = first_port.split(":", maxsplit=1)[0].strip()
516
+ if host_port.isdigit():
517
+ return int(host_port)
518
+ return 8080
519
+
520
+
521
+ def _infer_existing_workspace_service(service_root: Path) -> ServiceSpec:
522
+ return {
523
+ "name": service_root.name,
524
+ "type": "platform",
525
+ "role": "custom",
526
+ "port": _infer_port_from_service_root(service_root),
527
+ "features": [],
528
+ "requires": [],
529
+ }
530
+
531
+
532
+ def _ensure_unique_ports(services: list[ServiceSpec], preserve_name: str) -> None:
533
+ preserve = next((service for service in services if service["name"] == preserve_name), None)
534
+ used: set[int] = set()
535
+ if preserve is not None:
536
+ used.add(int(preserve["port"]))
537
+
538
+ for service in services:
539
+ if service["name"] == preserve_name:
540
+ continue
541
+ port = int(service["port"])
542
+ while port in used:
543
+ port += 1
544
+ service["port"] = port
545
+ used.add(port)
546
+
547
+
548
+ def _maybe_update_workspace_cluster(
549
+ workspace_root: Path,
550
+ service_config: GenerateServiceArgs,
551
+ generated_service: Path,
552
+ ) -> Path | None:
553
+ spec_path = _workspace_cluster_spec_path(workspace_root)
554
+ if spec_path.is_file():
555
+ updated_spec = _append_service_to_workspace_cluster(workspace_root, service_config)
556
+ return generate_cluster(updated_spec, workspace_root, force=True)
557
+
558
+ service_roots = _workspace_service_roots(workspace_root)
559
+ if len(service_roots) < 2:
560
+ return None
561
+
562
+ generated_name = generated_service.name
563
+ services: list[ServiceSpec] = [_service_config_to_cluster_service(service_config)]
564
+ for service_root in service_roots:
565
+ if service_root.name != generated_name:
566
+ services.append(_infer_existing_workspace_service(service_root))
567
+
568
+ _ensure_unique_ports(services, preserve_name=generated_name)
569
+
570
+ bootstrap_spec: ClusterSpec = {
571
+ "cluster": {"name": workspace_root.name, "preset": "none"},
572
+ "services": services,
573
+ }
574
+ normalized = normalize_cluster_spec(bootstrap_spec)
575
+ _save_workspace_cluster_spec(workspace_root, normalized)
576
+ return generate_cluster(normalized, workspace_root, force=True)
577
+
578
+
266
579
  def _build_parser() -> argparse.ArgumentParser:
267
580
  parser = argparse.ArgumentParser(prog="csrd", description="csrd utilities")
268
581
  parser.add_argument(
@@ -342,6 +655,18 @@ def _build_parser() -> argparse.ArgumentParser:
342
655
  )
343
656
  cluster.add_argument("--force", action="store_true", help="Overwrite existing generated path")
344
657
 
658
+ workspace = new_sub.add_parser(
659
+ "workspace", help="Create an empty workspace and optionally generate service/cluster"
660
+ )
661
+ workspace.add_argument("--name", help="Workspace directory name")
662
+ workspace.add_argument("--output", type=Path, default=Path.cwd(), help="Output directory")
663
+ workspace.add_argument(
664
+ "--generate",
665
+ choices=["none", "service", "cluster"],
666
+ help="Generate an asset inside the workspace after creation",
667
+ )
668
+ workspace.add_argument("--force", action="store_true", help="Overwrite existing workspace path")
669
+
345
670
  return parser
346
671
 
347
672
 
@@ -388,7 +713,15 @@ def main() -> int:
388
713
  if args.command == "new" and args.new_command == "service":
389
714
  try:
390
715
  service_config = _collect_new_service_config(args)
716
+ service_config["output_dir"] = _ensure_workspace_for_output(
717
+ service_config["output_dir"], allow_prompt=_service_is_interactive(args)
718
+ )
391
719
  generated = generate_service(**service_config)
720
+ generated_cluster = _maybe_update_workspace_cluster(
721
+ service_config["output_dir"], service_config, generated
722
+ )
723
+ if generated_cluster is not None:
724
+ print(f"Refreshed cluster scaffold at: {generated_cluster}")
392
725
  except (FileExistsError, ValueError) as exc:
393
726
  print(f"ERROR: {exc}")
394
727
  return 1
@@ -401,11 +734,15 @@ def main() -> int:
401
734
  if args.command == "new" and args.new_command == "cluster":
402
735
  try:
403
736
  cluster_config = _collect_new_cluster_config(args)
737
+ cluster_config["output_dir"] = _ensure_workspace_for_output(
738
+ cluster_config["output_dir"], allow_prompt=args.spec is None
739
+ )
404
740
  generated = generate_cluster(
405
741
  cluster_config["spec"],
406
742
  cluster_config["output_dir"],
407
743
  force=cluster_config["overwrite_if_exists"],
408
744
  )
745
+ _save_workspace_cluster_spec(cluster_config["output_dir"], cluster_config["spec"])
409
746
  except (FileExistsError, FileNotFoundError, ValueError) as exc:
410
747
  print(f"ERROR: {exc}")
411
748
  return 1
@@ -415,6 +752,39 @@ def main() -> int:
415
752
  print(f"Generated cluster at: {generated}")
416
753
  return 0
417
754
 
755
+ if args.command == "new" and args.new_command == "workspace":
756
+ try:
757
+ workspace_config = _collect_new_workspace_config(args)
758
+ workspace_root = _create_workspace(workspace_config)
759
+ print(f"Generated workspace at: {workspace_root}")
760
+
761
+ generate_mode = workspace_config["generate"]
762
+ if generate_mode == "service":
763
+ service_config = _collect_new_service_config(
764
+ _default_service_namespace(workspace_root)
765
+ )
766
+ service_config["output_dir"] = workspace_root
767
+ generated_service = generate_service(**service_config)
768
+ print(f"Generated service at: {generated_service}")
769
+ elif generate_mode == "cluster":
770
+ cluster_config = _collect_new_cluster_config(
771
+ _default_cluster_namespace(workspace_root)
772
+ )
773
+ cluster_config["output_dir"] = workspace_root
774
+ generated_cluster = generate_cluster(
775
+ cluster_config["spec"],
776
+ cluster_config["output_dir"],
777
+ force=cluster_config["overwrite_if_exists"],
778
+ )
779
+ print(f"Generated cluster at: {generated_cluster}")
780
+ except (FileExistsError, FileNotFoundError, ValueError) as exc:
781
+ print(f"ERROR: {exc}")
782
+ return 1
783
+ except KeyboardInterrupt:
784
+ _print_cancelled()
785
+ return 1
786
+ return 0
787
+
418
788
  if args.command == "doctor":
419
789
  return _run_doctor(args.service, args.json)
420
790
 
File without changes