csrd-utils 0.3.22__tar.gz → 0.3.23__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 (87) hide show
  1. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/PKG-INFO +12 -1
  2. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/README.md +11 -0
  3. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/pyproject.toml +1 -1
  4. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/__main__.py +221 -1
  5. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/.gitignore +0 -0
  6. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/__init__.py +0 -0
  7. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/audit.py +0 -0
  8. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/augmentor.py +0 -0
  9. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/cluster.py +0 -0
  10. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/doctor.py +0 -0
  11. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/caching/conftest.fragment.py +0 -0
  12. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/caching/docker-compose.fragment.yaml +0 -0
  13. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/caching/manifest.yaml +0 -0
  14. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/caching/requirements.fragment +0 -0
  15. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/caching/settings.fragment.py +0 -0
  16. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/caching/src/app/cache.py +0 -0
  17. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/caching/tests/test_cache.py +0 -0
  18. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/logging/conftest.fragment.py +0 -0
  19. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/logging/manifest.yaml +0 -0
  20. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/logging/requirements.fragment +0 -0
  21. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/logging/settings.fragment.py +0 -0
  22. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/logging/src/app/middleware/logging.py +0 -0
  23. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/logging/tests/test_logging_middleware.py +0 -0
  24. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/metrics/conftest.fragment.py +0 -0
  25. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/metrics/docker-compose.fragment.yaml +0 -0
  26. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/metrics/manifest.yaml +0 -0
  27. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/metrics/requirements.fragment +0 -0
  28. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/metrics/settings.fragment.py +0 -0
  29. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/metrics/src/app/middleware/metrics.py +0 -0
  30. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/metrics/tests/test_metrics_middleware.py +0 -0
  31. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/tracing/conftest.fragment.py +0 -0
  32. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/tracing/manifest.yaml +0 -0
  33. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/tracing/requirements.fragment +0 -0
  34. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/tracing/settings.fragment.py +0 -0
  35. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/tracing/src/app/middleware/tracing.py +0 -0
  36. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/tracing/tests/test_tracing_middleware.py +0 -0
  37. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  38. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  39. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  40. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  41. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  42. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  43. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  44. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  45. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  46. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/fragments.py +0 -0
  47. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/generator.py +0 -0
  48. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/resources.py +0 -0
  49. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  50. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  51. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  52. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  53. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
  54. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.gitignore +0 -0
  55. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  56. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
  57. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
  58. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/pyproject.toml +0 -0
  59. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  60. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +0 -0
  61. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
  62. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
  63. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
  64. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
  65. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
  66. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
  67. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
  68. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
  69. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
  70. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/py.typed +0 -0
  71. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
  72. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
  73. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +0 -0
  74. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
  75. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -0
  76. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
  77. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -0
  78. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
  79. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
  80. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  81. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/acceptance/test_logging_middleware.py +0 -0
  82. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
  83. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +0 -0
  84. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_cache.py +0 -0
  85. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_database_settings.py +0 -0
  86. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_delegates.py +0 -0
  87. {csrd_utils-0.3.22 → csrd_utils-0.3.23}/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.22
3
+ Version: 0.3.23
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
@@ -44,6 +44,16 @@ csrd audit --service . # explicitly audit one servi
44
44
 
45
45
  `feature plan --json` is useful for CI or tooling wrappers.
46
46
 
47
+ ### Planned: workspace extraction command
48
+
49
+ Service extraction into a sibling workspace is planned as a future command:
50
+
51
+ ```bash
52
+ csrd workspace extract-service --service ./source-ws/src/pricing --target-workspace ../pricing-ws --mode copy
53
+ ```
54
+
55
+ Until that lands, follow `docs/SERVICE_EXTRACTION_PLAYBOOK.md` for the current manual workflow.
56
+
47
57
  ### Interactive generation behavior
48
58
 
49
59
  - `csrd new service` and `csrd new cluster` are workspace-first commands.
@@ -112,6 +122,7 @@ with features_path() as feature_lib:
112
122
  - `docs/AGENT_SMOKE_TESTS.md` - copy/paste validation flow for clean workspaces
113
123
  - `docs/COMMAND_MATRIX.yaml` - machine-readable command matrix for automation
114
124
  - `AGENTS.md` - package-local rules for autonomous agents
125
+ - `../../docs/SERVICE_EXTRACTION_PLAYBOOK.md` - workspace service extraction workflow and status
115
126
 
116
127
  ## CLI-only agent mode
117
128
 
@@ -31,6 +31,16 @@ csrd audit --service . # explicitly audit one servi
31
31
 
32
32
  `feature plan --json` is useful for CI or tooling wrappers.
33
33
 
34
+ ### Planned: workspace extraction command
35
+
36
+ Service extraction into a sibling workspace is planned as a future command:
37
+
38
+ ```bash
39
+ csrd workspace extract-service --service ./source-ws/src/pricing --target-workspace ../pricing-ws --mode copy
40
+ ```
41
+
42
+ Until that lands, follow `docs/SERVICE_EXTRACTION_PLAYBOOK.md` for the current manual workflow.
43
+
34
44
  ### Interactive generation behavior
35
45
 
36
46
  - `csrd new service` and `csrd new cluster` are workspace-first commands.
@@ -99,6 +109,7 @@ with features_path() as feature_lib:
99
109
  - `docs/AGENT_SMOKE_TESTS.md` - copy/paste validation flow for clean workspaces
100
110
  - `docs/COMMAND_MATRIX.yaml` - machine-readable command matrix for automation
101
111
  - `AGENTS.md` - package-local rules for autonomous agents
112
+ - `../../docs/SERVICE_EXTRACTION_PLAYBOOK.md` - workspace service extraction workflow and status
102
113
 
103
114
  ## CLI-only agent mode
104
115
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.22"
3
+ version = "0.3.23"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -2,10 +2,11 @@
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import shutil
5
6
  from contextlib import suppress
6
7
  from importlib.metadata import version
7
8
  from pathlib import Path
8
- from typing import TypedDict
9
+ from typing import TypedDict, cast
9
10
  from urllib.parse import urlparse
10
11
 
11
12
  import yaml # type: ignore[import-untyped]
@@ -57,6 +58,15 @@ class GenerateWorkspaceArgs(TypedDict):
57
58
  generate: str
58
59
 
59
60
 
61
+ class ExtractWorkspaceServiceArgs(TypedDict):
62
+ service_root: Path
63
+ target_workspace: Path
64
+ mode: str
65
+ force: bool
66
+ dry_run: bool
67
+ as_json: bool
68
+
69
+
60
70
  WORKSPACE_MARKER = ".csrd-workspace"
61
71
  WORKSPACE_CLUSTER_SPEC = Path(".csrd") / "cluster.yaml"
62
72
 
@@ -545,9 +555,179 @@ def _workspace_root_for_service(service_root: Path) -> Path | None:
545
555
  parent = service_root.parent
546
556
  if parent.name == "src" and _is_workspace(parent.parent):
547
557
  return parent.parent
558
+ if _is_workspace(parent):
559
+ return parent
548
560
  return None
549
561
 
550
562
 
563
+ def _validate_service_root(service_root: Path) -> None:
564
+ if not service_root.is_dir():
565
+ raise ValueError(f"Service path is not a directory: {service_root}")
566
+ if not (service_root / "src" / "app").is_dir():
567
+ raise ValueError(f"Missing required directory: {service_root / 'src' / 'app'}")
568
+ if not (service_root / "tests").is_dir():
569
+ raise ValueError(f"Missing required directory: {service_root / 'tests'}")
570
+ if not (service_root / "requirements.txt").is_file():
571
+ raise ValueError(f"Missing required file: {service_root / 'requirements.txt'}")
572
+
573
+
574
+ def _collect_workspace_extract_config(args: argparse.Namespace) -> ExtractWorkspaceServiceArgs:
575
+ return ExtractWorkspaceServiceArgs(
576
+ {
577
+ "service_root": args.service.resolve(),
578
+ "target_workspace": args.target_workspace.resolve(),
579
+ "mode": args.mode,
580
+ "force": args.force,
581
+ "dry_run": args.dry_run,
582
+ "as_json": args.json,
583
+ }
584
+ )
585
+
586
+
587
+ def _service_spec_from_workspace(service_root: Path, workspace_root: Path) -> ServiceSpec:
588
+ spec = _load_workspace_cluster_spec(workspace_root)
589
+ services = spec.get("services", [])
590
+ for service in services:
591
+ if str(service.get("name", "")).strip() == service_root.name:
592
+ return cast(ServiceSpec, dict(service))
593
+ return _infer_existing_workspace_service(service_root)
594
+
595
+
596
+ def _upsert_workspace_service(
597
+ spec: ClusterSpec, service: ServiceSpec, workspace_name: str
598
+ ) -> ClusterSpec:
599
+ services: list[ServiceSpec] = [
600
+ cast(ServiceSpec, dict(entry))
601
+ for entry in spec.get("services", [])
602
+ if str(entry.get("name", "")).strip() != str(service["name"])
603
+ ]
604
+ services.append(cast(ServiceSpec, dict(service)))
605
+ _ensure_unique_ports(services, preserve_name=str(service["name"]))
606
+
607
+ cluster = spec.get("cluster", {})
608
+ candidate: ClusterSpec = {
609
+ "cluster": {
610
+ "name": str(cluster.get("name", "")).strip() or workspace_name,
611
+ "preset": str(cluster.get("preset", "none")).strip() or "none",
612
+ },
613
+ "services": services,
614
+ }
615
+ return normalize_cluster_spec(candidate)
616
+
617
+
618
+ def _remove_workspace_service(
619
+ spec: ClusterSpec, service_name: str, workspace_name: str
620
+ ) -> ClusterSpec:
621
+ services: list[ServiceSpec] = [
622
+ cast(ServiceSpec, dict(entry))
623
+ for entry in spec.get("services", [])
624
+ if str(entry.get("name", "")).strip() != service_name
625
+ ]
626
+ cluster = spec.get("cluster", {})
627
+ candidate: ClusterSpec = {
628
+ "cluster": {
629
+ "name": str(cluster.get("name", "")).strip() or workspace_name,
630
+ "preset": str(cluster.get("preset", "none")).strip() or "none",
631
+ },
632
+ "services": services,
633
+ }
634
+ return normalize_cluster_spec(candidate)
635
+
636
+
637
+ def _extract_service(config: ExtractWorkspaceServiceArgs) -> dict[str, object]:
638
+ service_root = config["service_root"]
639
+ target_workspace = config["target_workspace"]
640
+ mode = config["mode"]
641
+
642
+ _validate_service_root(service_root)
643
+ source_workspace = _workspace_root_for_service(service_root)
644
+ if source_workspace is None:
645
+ raise ValueError("Service is not inside a csrd workspace")
646
+ if source_workspace == target_workspace:
647
+ raise ValueError("Source and target workspace must be different paths")
648
+
649
+ source_service_spec = _service_spec_from_workspace(service_root, source_workspace)
650
+ target_services_dir = _workspace_services_dir(target_workspace)
651
+ target_service_root = target_services_dir / service_root.name
652
+
653
+ actions = [
654
+ f"source_workspace={source_workspace}",
655
+ f"target_workspace={target_workspace}",
656
+ f"service={service_root.name}",
657
+ f"mode={mode}",
658
+ ]
659
+
660
+ if not _is_workspace(target_workspace):
661
+ if target_workspace.exists() and any(target_workspace.iterdir()):
662
+ raise ValueError(
663
+ f"Target exists but is not a workspace: {target_workspace}. "
664
+ "Create a workspace marker first or use an empty directory."
665
+ )
666
+ actions.append("create_target_workspace_marker")
667
+
668
+ if target_service_root.exists() and not config["force"]:
669
+ raise FileExistsError(
670
+ f"Target service already exists: {target_service_root}. Use --force to overwrite."
671
+ )
672
+ if target_service_root.exists():
673
+ actions.append("overwrite_target_service")
674
+
675
+ if mode == "move":
676
+ actions.append("remove_source_service")
677
+
678
+ if config["dry_run"]:
679
+ return {
680
+ "ok": True,
681
+ "dry_run": True,
682
+ "source_workspace": str(source_workspace),
683
+ "target_workspace": str(target_workspace),
684
+ "service": service_root.name,
685
+ "mode": mode,
686
+ "actions": actions,
687
+ }
688
+
689
+ target_workspace.mkdir(parents=True, exist_ok=True)
690
+ if not _is_workspace(target_workspace):
691
+ _write_workspace_marker(target_workspace)
692
+
693
+ target_services_dir.mkdir(parents=True, exist_ok=True)
694
+ if target_service_root.exists():
695
+ shutil.rmtree(target_service_root)
696
+ shutil.copytree(service_root, target_service_root)
697
+
698
+ target_spec = _upsert_workspace_service(
699
+ _load_workspace_cluster_spec(target_workspace), source_service_spec, target_workspace.name
700
+ )
701
+ _save_workspace_cluster_spec(target_workspace, target_spec)
702
+ target_cluster_root = generate_cluster(
703
+ target_spec, target_workspace, force=True, workspace_mode=True
704
+ )
705
+
706
+ source_cluster_root = None
707
+ if mode == "move":
708
+ shutil.rmtree(service_root)
709
+ source_spec = _remove_workspace_service(
710
+ _load_workspace_cluster_spec(source_workspace), service_root.name, source_workspace.name
711
+ )
712
+ _save_workspace_cluster_spec(source_workspace, source_spec)
713
+ source_cluster_root = generate_cluster(
714
+ source_spec, source_workspace, force=True, workspace_mode=True
715
+ )
716
+
717
+ return {
718
+ "ok": True,
719
+ "dry_run": False,
720
+ "source_workspace": str(source_workspace),
721
+ "target_workspace": str(target_workspace),
722
+ "service": service_root.name,
723
+ "mode": mode,
724
+ "target_service": str(target_service_root),
725
+ "target_cluster": str(target_cluster_root),
726
+ "source_cluster": str(source_cluster_root) if source_cluster_root else None,
727
+ "actions": actions,
728
+ }
729
+
730
+
551
731
  def _infer_port_from_compose(compose_path: Path, service_name: str | None = None) -> int:
552
732
  try:
553
733
  payload = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
@@ -752,6 +932,21 @@ def _build_parser() -> argparse.ArgumentParser:
752
932
  )
753
933
  workspace.add_argument("--force", action="store_true", help="Overwrite existing workspace path")
754
934
 
935
+ workspace_cmd = sub.add_parser("workspace", help="Workspace operations")
936
+ workspace_sub = workspace_cmd.add_subparsers(dest="workspace_command", required=True)
937
+
938
+ extract = workspace_sub.add_parser(
939
+ "extract-service", help="Copy or move a service into a sibling workspace"
940
+ )
941
+ extract.add_argument("--service", type=Path, required=True, help="Source service root path")
942
+ extract.add_argument(
943
+ "--target-workspace", type=Path, required=True, help="Target workspace root path"
944
+ )
945
+ extract.add_argument("--mode", choices=["copy", "move"], default="copy")
946
+ extract.add_argument("--dry-run", action="store_true", help="Show extraction plan only")
947
+ extract.add_argument("--json", action="store_true", help="Print extraction result as JSON")
948
+ extract.add_argument("--force", action="store_true", help="Overwrite target service path")
949
+
755
950
  return parser
756
951
 
757
952
 
@@ -960,6 +1155,31 @@ def main() -> int:
960
1155
  return 1
961
1156
  return 0
962
1157
 
1158
+ if args.command == "workspace" and args.workspace_command == "extract-service":
1159
+ try:
1160
+ result = _extract_service(_collect_workspace_extract_config(args))
1161
+ except (FileExistsError, ValueError) as exc:
1162
+ print(f"ERROR: {exc}")
1163
+ return 1
1164
+
1165
+ if args.json:
1166
+ print(json.dumps(result, indent=2, sort_keys=True))
1167
+ return 0
1168
+
1169
+ if result.get("dry_run"):
1170
+ print("Extraction plan (dry-run):")
1171
+ actions = result.get("actions")
1172
+ if isinstance(actions, list):
1173
+ for action in actions:
1174
+ print(f"- {action}")
1175
+ return 0
1176
+
1177
+ print(f"Extracted service to: {result['target_service']}")
1178
+ print(f"Refreshed target cluster scaffold at: {result['target_cluster']}")
1179
+ if result.get("source_cluster"):
1180
+ print(f"Refreshed source cluster scaffold at: {result['source_cluster']}")
1181
+ return 0
1182
+
963
1183
  if args.command == "doctor":
964
1184
  return _run_doctor(args.service, args.json)
965
1185
 
File without changes