devctl 1.0.0__py3-none-any.whl

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 (92) hide show
  1. devctl/__init__.py +3 -0
  2. devctl/commands/__init__.py +3 -0
  3. devctl/commands/add.py +166 -0
  4. devctl/commands/deploy.py +61 -0
  5. devctl/commands/docker.py +65 -0
  6. devctl/commands/init.py +193 -0
  7. devctl/commands/run.py +67 -0
  8. devctl/generators/__init__.py +3 -0
  9. devctl/generators/angular.py +112 -0
  10. devctl/generators/django.py +61 -0
  11. devctl/generators/docker_scaffold.py +656 -0
  12. devctl/generators/fastapi.py +67 -0
  13. devctl/generators/go_fiber.py +61 -0
  14. devctl/generators/nestjs.py +49 -0
  15. devctl/generators/nextjs.py +53 -0
  16. devctl/generators/nodejs.py +109 -0
  17. devctl/generators/react.py +43 -0
  18. devctl/generators/scaffold_angular.py +163 -0
  19. devctl/generators/scaffold_django.py +79 -0
  20. devctl/generators/scaffold_fastapi.py +83 -0
  21. devctl/generators/scaffold_go.py +67 -0
  22. devctl/generators/scaffold_nestjs.py +52 -0
  23. devctl/generators/scaffold_nextjs.py +73 -0
  24. devctl/generators/scaffold_nodejs.py +81 -0
  25. devctl/generators/scaffold_react.py +80 -0
  26. devctl/generators/scaffold_spring.py +166 -0
  27. devctl/generators/scaffold_svelte.py +73 -0
  28. devctl/generators/scaffold_vue.py +111 -0
  29. devctl/generators/spring.py +221 -0
  30. devctl/generators/svelte.py +52 -0
  31. devctl/generators/vue.py +105 -0
  32. devctl/main.py +45 -0
  33. devctl/orchestrator/__init__.py +3 -0
  34. devctl/orchestrator/config_builder.py +64 -0
  35. devctl/orchestrator/runner.py +219 -0
  36. devctl/orchestrator/scanner.py +155 -0
  37. devctl/templates/angular/config/environment.development.ts.j2 +4 -0
  38. devctl/templates/angular/config/environment.ts.j2 +5 -0
  39. devctl/templates/angular/config/proxy.conf.json.j2 +8 -0
  40. devctl/templates/angular/feature/models/request.model.ts.j2 +5 -0
  41. devctl/templates/angular/feature/models/response.model.ts.j2 +6 -0
  42. devctl/templates/angular/feature/pages/form/form.component.html.j2 +21 -0
  43. devctl/templates/angular/feature/pages/form/form.component.scss.j2 +0 -0
  44. devctl/templates/angular/feature/pages/form/form.component.ts.j2 +63 -0
  45. devctl/templates/angular/feature/pages/list/list.component.html.j2 +28 -0
  46. devctl/templates/angular/feature/pages/list/list.component.scss.j2 +0 -0
  47. devctl/templates/angular/feature/pages/list/list.component.ts.j2 +34 -0
  48. devctl/templates/angular/feature/routes.ts.j2 +9 -0
  49. devctl/templates/angular/feature/services/service.ts.j2 +34 -0
  50. devctl/templates/docker/deploy.yml.j2 +37 -0
  51. devctl/templates/docker/django/Dockerfile.j2 +21 -0
  52. devctl/templates/docker/fastapi/Dockerfile.j2 +15 -0
  53. devctl/templates/docker/frontend/Dockerfile.j2 +31 -0
  54. devctl/templates/docker/go/Dockerfile.j2 +24 -0
  55. devctl/templates/docker/nestjs/Dockerfile.j2 +26 -0
  56. devctl/templates/docker/nextjs/Dockerfile.j2 +43 -0
  57. devctl/templates/docker/nodejs/Dockerfile.j2 +24 -0
  58. devctl/templates/docker/spring/Dockerfile.j2 +24 -0
  59. devctl/templates/docker/svelte/Dockerfile.j2 +24 -0
  60. devctl/templates/proxy.conf.json.j2 +0 -0
  61. devctl/templates/spring/Controller.java.j2 +50 -0
  62. devctl/templates/spring/Entity.java.j2 +22 -0
  63. devctl/templates/spring/Repository.java.j2 +9 -0
  64. devctl/templates/spring/Service.java.j2 +20 -0
  65. devctl/templates/spring/ServiceImpl.java.j2 +62 -0
  66. devctl/templates/spring/application.properties.j2 +19 -0
  67. devctl/templates/spring/config/ApplicationConfig.java.j2 +46 -0
  68. devctl/templates/spring/config/JwtAuthenticationFilter.java.j2 +54 -0
  69. devctl/templates/spring/config/JwtService.java.j2 +68 -0
  70. devctl/templates/spring/config/SecurityConfig.java.j2 +42 -0
  71. devctl/templates/spring/docker-compose.yml.j2 +29 -0
  72. devctl/templates/spring/dto/Request.java.j2 +20 -0
  73. devctl/templates/spring/dto/Response.java.j2 +22 -0
  74. devctl/templates/spring/mapper/Mapper.java.j2 +30 -0
  75. devctl/templates/vue/config/App.vue.j2 +35 -0
  76. devctl/templates/vue/config/main.ts.j2 +9 -0
  77. devctl/templates/vue/config/router.ts.j2 +18 -0
  78. devctl/templates/vue/config/vite.config.ts.j2 +16 -0
  79. devctl/templates/vue/feature/Form.vue.j2 +162 -0
  80. devctl/templates/vue/feature/List.vue.j2 +155 -0
  81. devctl/templates/vue/feature/models.ts.j2 +12 -0
  82. devctl/templates/vue/feature/routes.ts.j2 +19 -0
  83. devctl/templates/vue/feature/service.ts.j2 +44 -0
  84. devctl/utils/__init__.py +3 -0
  85. devctl/utils/dependencies.py +36 -0
  86. devctl/utils/env_loader.py +57 -0
  87. devctl-1.0.0.dist-info/METADATA +127 -0
  88. devctl-1.0.0.dist-info/RECORD +92 -0
  89. devctl-1.0.0.dist-info/WHEEL +5 -0
  90. devctl-1.0.0.dist-info/entry_points.txt +2 -0
  91. devctl-1.0.0.dist-info/licenses/LICENSE +21 -0
  92. devctl-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,656 @@
1
+ """Dockerfile scaffolding for supported devctl projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import xml.etree.ElementTree as ET
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional, Union
12
+
13
+ import yaml
14
+ from jinja2 import Environment, FileSystemLoader
15
+
16
+ IGNORED_DIRECTORIES = {
17
+ ".angular",
18
+ ".git",
19
+ ".mvn",
20
+ ".next",
21
+ ".pytest_cache",
22
+ ".venv",
23
+ "__pycache__",
24
+ "build",
25
+ "coverage",
26
+ "dist",
27
+ "node_modules",
28
+ "target",
29
+ "venv",
30
+ }
31
+
32
+
33
+ class DockerScaffoldError(Exception):
34
+ """Raised when Dockerfile scaffolding cannot be completed."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DockerProject:
39
+ """A project that can receive a generated Dockerfile."""
40
+
41
+ kind: str
42
+ path: Path
43
+ name: str
44
+ service_name: str
45
+ relative_context: str
46
+ java_version: Optional[str] = None
47
+ node_version: Optional[str] = None
48
+ angular_output_name: Optional[str] = None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class FileOperation:
53
+ """A single file operation performed or planned by the scaffold command."""
54
+
55
+ path: Path
56
+ action: str
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class DockerScaffoldResult:
61
+ """Summary returned after Dockerfile scaffolding."""
62
+
63
+ root_path: Path
64
+ services: list[DockerProject]
65
+ operations: list[FileOperation]
66
+
67
+ @property
68
+ def created_count(self) -> int:
69
+ return sum(1 for operation in self.operations if operation.action == "created")
70
+
71
+ @property
72
+ def skipped_count(self) -> int:
73
+ return sum(1 for operation in self.operations if operation.action == "skipped")
74
+
75
+ @property
76
+ def planned_count(self) -> int:
77
+ return sum(1 for operation in self.operations if operation.action.startswith("would_"))
78
+
79
+
80
+ def sanitize_service_name(raw_name: str, fallback: str = "service") -> str:
81
+ """Return a lowercase Docker-friendly service name."""
82
+ service_name = re.sub(r"[^a-z0-9-]+", "-", raw_name.lower()).strip("-")
83
+ service_name = re.sub(r"-{2,}", "-", service_name)
84
+ return service_name or fallback
85
+
86
+
87
+ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject]:
88
+ """Discover all supported projects under ``root_path``."""
89
+ root = Path(root_path).resolve()
90
+ if not root.exists():
91
+ raise DockerScaffoldError(f"Path does not exist: {root}")
92
+ if not root.is_dir():
93
+ raise DockerScaffoldError(f"Path is not a directory: {root}")
94
+
95
+ candidates: list[tuple[str, Path]] = []
96
+
97
+ for dirpath, dirnames, filenames in os.walk(root):
98
+ dirnames[:] = [name for name in dirnames if name not in IGNORED_DIRECTORIES]
99
+
100
+ project_path = Path(dirpath)
101
+ filename_set = set(filenames)
102
+
103
+ if "pom.xml" in filename_set:
104
+ candidates.append(("spring", project_path))
105
+ if "angular.json" in filename_set:
106
+ candidates.append(("angular", project_path))
107
+
108
+ has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set
109
+ if has_vite_config and "angular.json" not in filename_set:
110
+ # Check package.json to distinguish between vue and react
111
+ pkg_path = project_path / "package.json"
112
+ if pkg_path.exists():
113
+ try:
114
+ pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
115
+ deps = pkg.get("dependencies", {})
116
+ dev_deps = pkg.get("devDependencies", {})
117
+ all_deps = {**deps, **dev_deps}
118
+
119
+ if "vue" in all_deps:
120
+ candidates.append(("vue", project_path))
121
+ elif "react" in all_deps:
122
+ candidates.append(("react", project_path))
123
+ else:
124
+ candidates.append(("vue", project_path)) # Fallback to vue
125
+ except Exception:
126
+ candidates.append(("vue", project_path))
127
+ else:
128
+ candidates.append(("vue", project_path))
129
+
130
+ if "nest-cli.json" in filename_set:
131
+ candidates.append(("nest", project_path))
132
+
133
+ if any(f.startswith("next.config.") for f in filename_set):
134
+ candidates.append(("nextjs", project_path))
135
+
136
+ if "svelte.config.js" in filename_set:
137
+ candidates.append(("svelte", project_path))
138
+
139
+ if "main.py" in filename_set and "requirements.txt" in filename_set:
140
+ try:
141
+ reqs = (project_path / "requirements.txt").read_text(encoding="utf-8")
142
+ if "fastapi" in reqs.lower():
143
+ candidates.append(("fastapi", project_path))
144
+ elif "django" in reqs.lower():
145
+ candidates.append(("django", project_path))
146
+ except (OSError, UnicodeDecodeError):
147
+ # Best-effort discovery: unreadable requirements files should not stop scanning.
148
+ pass
149
+
150
+ if "manage.py" in filename_set and "requirements.txt" in filename_set:
151
+ try:
152
+ reqs = (project_path / "requirements.txt").read_text(encoding="utf-8")
153
+ if "django" in reqs.lower():
154
+ candidates.append(("django", project_path))
155
+ except (OSError, UnicodeDecodeError):
156
+ # Best-effort discovery: ignore unreadable/invalid requirements.txt here.
157
+ pass
158
+
159
+ if "go.mod" in filename_set:
160
+ candidates.append(("go", project_path))
161
+
162
+ if "package.json" in filename_set and not any(
163
+ k in ["angular", "vue", "react", "nest", "nextjs", "svelte"]
164
+ for k, p in candidates
165
+ if p == project_path
166
+ ):
167
+ candidates.append(("nodejs", project_path))
168
+
169
+ used_names: set[str] = set()
170
+ projects: list[DockerProject] = []
171
+
172
+ for kind, project_path in sorted(candidates, key=lambda item: (str(item[1]), item[0])):
173
+ name = _project_name(kind, project_path)
174
+ service_name = _unique_service_name(name, used_names, fallback=kind)
175
+ used_names.add(service_name)
176
+
177
+ projects.append(
178
+ DockerProject(
179
+ kind=kind,
180
+ path=project_path,
181
+ name=name,
182
+ service_name=service_name,
183
+ relative_context=_relative_context(root, project_path),
184
+ java_version=_spring_java_version(project_path) if kind == "spring" else None,
185
+ node_version=(
186
+ _node_version(project_path, kind)
187
+ if kind in {"angular", "vue", "react", "nest", "nodejs", "nextjs", "svelte"}
188
+ else None
189
+ ),
190
+ angular_output_name=(
191
+ _angular_output_name(project_path) if kind == "angular" else None
192
+ ),
193
+ )
194
+ )
195
+
196
+ return projects
197
+
198
+
199
+ def scaffold_docker_assets(
200
+ root_path: Union[str, Path] = ".",
201
+ *,
202
+ force: bool = False,
203
+ dry_run: bool = False,
204
+ ) -> DockerScaffoldResult:
205
+ """Generate Dockerfiles and docker-compose-prod.yml for all supported projects in a tree."""
206
+ root = Path(root_path).resolve()
207
+ projects = discover_docker_projects(root)
208
+ if not projects:
209
+ raise DockerScaffoldError("No supported project detected.")
210
+
211
+ env = _template_environment()
212
+ operations = [
213
+ _write_file(
214
+ project.path / "Dockerfile",
215
+ _dockerfile_content(env, project),
216
+ force=force,
217
+ dry_run=dry_run,
218
+ )
219
+ for project in projects
220
+ ]
221
+
222
+ # Also scaffold the global docker-compose-prod.yml
223
+ compose_path = root / "docker-compose-prod.yml"
224
+ compose_content = _generate_compose_content(projects)
225
+ operations.append(
226
+ _write_file(
227
+ compose_path,
228
+ compose_content,
229
+ force=force,
230
+ dry_run=dry_run,
231
+ )
232
+ )
233
+
234
+ return DockerScaffoldResult(root_path=root, services=projects, operations=operations)
235
+
236
+
237
+ def _generate_compose_content(projects: list[DockerProject]) -> str:
238
+ services_data = []
239
+ databases = []
240
+ seen_db_names = set()
241
+
242
+ for project in projects:
243
+ service_dict = {
244
+ "service_name": project.service_name,
245
+ "kind": project.kind,
246
+ "relative_context": project.relative_context,
247
+ "db": None,
248
+ }
249
+
250
+ if project.kind == "spring":
251
+ db_info = extract_db_info(project.path)
252
+ if db_info:
253
+ service_dict["db"] = db_info
254
+ if db_info["service_name"] not in seen_db_names:
255
+ databases.append(db_info)
256
+ seen_db_names.add(db_info["service_name"])
257
+
258
+ services_data.append(service_dict)
259
+
260
+ template_dir = Path(__file__).resolve().parent.parent / "templates" / "docker"
261
+ env = Environment(
262
+ loader=FileSystemLoader(str(template_dir)),
263
+ trim_blocks=True,
264
+ lstrip_blocks=True,
265
+ )
266
+ template = env.get_template("deploy.yml.j2")
267
+ return template.render(services=services_data, databases=databases)
268
+
269
+
270
+ def extract_db_info(project_path: Path) -> Optional[Dict[str, Any]]:
271
+ """
272
+ Extract database information from a Spring Boot project's application.properties.
273
+ """
274
+ props_path = project_path / "src" / "main" / "resources" / "application.properties"
275
+ if not props_path.exists():
276
+ # Fallback to checking for docker-compose-db.yml in the same dir
277
+ return extract_db_from_compose(project_path / "docker-compose-db.yml")
278
+
279
+ content = props_path.read_text(encoding="utf-8", errors="ignore")
280
+
281
+ # spring.datasource.url=jdbc:postgresql://localhost:5432/sample_api_db
282
+ url_match = re.search(r"spring\.datasource\.url=jdbc:([^:]+)://[^:]+:(\d+)/([\w-]+)", content)
283
+ # spring.data.mongodb.uri=mongodb://admin:password@localhost:27017/db_name
284
+ mongo_match = re.search(
285
+ r"spring\.data\.mongodb\.uri=mongodb://([^:]+):([^@]+)@[^:]+:(\d+)/([\w-]+)", content
286
+ )
287
+
288
+ user_match = re.search(r"spring\.datasource\.username=([\w-]+)", content)
289
+ pass_match = re.search(r"spring\.datasource\.password=([\w-]+)", content)
290
+
291
+ if not url_match and not mongo_match:
292
+ return extract_db_from_compose(project_path / "docker-compose-db.yml")
293
+
294
+ if mongo_match:
295
+ db_type = "mongodb"
296
+ db_user = mongo_match.group(1)
297
+ db_pass = mongo_match.group(2)
298
+ db_port = mongo_match.group(3)
299
+ db_name = mongo_match.group(4)
300
+ else:
301
+ db_type_raw = url_match.group(1)
302
+ db_type = "postgresql" if "postgres" in db_type_raw else "mysql"
303
+ db_port = url_match.group(2)
304
+ db_name = url_match.group(3)
305
+ db_user = user_match.group(1) if user_match else "admin"
306
+ db_pass = pass_match.group(1) if pass_match else "password"
307
+
308
+ db_dict = _build_db_dict(db_type, db_port, db_name, db_user, db_pass)
309
+
310
+ # Try to refine service name from existing docker-compose if possible
311
+ compose_path = project_path / "docker-compose-db.yml"
312
+ if compose_path.exists():
313
+ try:
314
+ with open(compose_path, "r", encoding="utf-8") as f:
315
+ config = yaml.safe_load(f)
316
+ if config and "services" in config:
317
+ # Find the first service that looks like a database
318
+ for s_name, s_cfg in config["services"].items():
319
+ image = str(s_cfg.get("image", ""))
320
+ if db_type == "postgresql" and "postgres" in image:
321
+ db_dict["service_name"] = s_name
322
+ break
323
+ if db_type == "mysql" and "mysql" in image:
324
+ db_dict["service_name"] = s_name
325
+ break
326
+ if db_type == "mongodb" and "mongo" in image:
327
+ db_dict["service_name"] = s_name
328
+ break
329
+ except (OSError, yaml.YAMLError):
330
+ pass
331
+
332
+ return db_dict
333
+
334
+
335
+ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]:
336
+ """
337
+ Extract database information from a docker-compose-db.yml file using PyYAML.
338
+ """
339
+ if not compose_path.exists():
340
+ return None
341
+
342
+ try:
343
+ with open(compose_path, "r", encoding="utf-8") as f:
344
+ config = yaml.safe_load(f)
345
+ except Exception:
346
+ return None
347
+
348
+ if not config or "services" not in config:
349
+ return None
350
+
351
+ for service_name, service_cfg in config["services"].items():
352
+ image = str(service_cfg.get("image", ""))
353
+ if "postgres" in image or "mysql" in image or "mongo" in image:
354
+ if "postgres" in image:
355
+ db_type = "postgresql"
356
+ elif "mysql" in image:
357
+ db_type = "mysql"
358
+ else:
359
+ db_type = "mongodb"
360
+
361
+ env = service_cfg.get("environment", {})
362
+ env_dict = {}
363
+ if isinstance(env, list):
364
+ for item in env:
365
+ if "=" in item:
366
+ k, v = item.split("=", 1)
367
+ env_dict[k] = v
368
+ elif ":" in item:
369
+ k, v = item.split(":", 1)
370
+ env_dict[k] = v.strip()
371
+ elif isinstance(env, dict):
372
+ env_dict = env
373
+
374
+ if db_type == "postgresql":
375
+ user = env_dict.get("POSTGRES_USER", "admin")
376
+ password = env_dict.get("POSTGRES_PASSWORD", "password")
377
+ db_name = env_dict.get("POSTGRES_DB", "db")
378
+ elif db_type == "mysql":
379
+ user = env_dict.get("MYSQL_USER", env_dict.get("MYSQL_ROOT_PASSWORD", "admin"))
380
+ password = env_dict.get(
381
+ "MYSQL_PASSWORD", env_dict.get("MYSQL_ROOT_PASSWORD", "password")
382
+ )
383
+ db_name = env_dict.get("MYSQL_DATABASE", "db")
384
+ else:
385
+ user = env_dict.get("MONGO_INITDB_ROOT_USERNAME", "admin")
386
+ password = env_dict.get("MONGO_INITDB_ROOT_PASSWORD", "password")
387
+ db_name = env_dict.get("MONGO_INITDB_DATABASE", "db")
388
+
389
+ ports = service_cfg.get("ports", [])
390
+ host_port = None
391
+ if ports and isinstance(ports, list):
392
+ first_port = str(ports[0])
393
+ if ":" in first_port:
394
+ host_port = first_port.split(":")[0].strip("'").strip('"')
395
+
396
+ db_dict = _build_db_dict(
397
+ db_type,
398
+ host_port
399
+ or (
400
+ "5432"
401
+ if db_type == "postgresql"
402
+ else ("3306" if db_type == "mysql" else "27017")
403
+ ),
404
+ db_name,
405
+ user,
406
+ password,
407
+ )
408
+ db_dict["service_name"] = service_name
409
+ return db_dict
410
+
411
+ return None
412
+
413
+
414
+ def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) -> Dict[str, Any]:
415
+ is_postgres = db_type == "postgresql"
416
+ is_mysql = db_type == "mysql"
417
+
418
+ if is_postgres:
419
+ internal_port = "5432"
420
+ image = "postgres:15-alpine"
421
+ vol_path = "/var/lib/postgresql/data"
422
+ elif is_mysql:
423
+ internal_port = "3306"
424
+ image = "mysql:8.0"
425
+ vol_path = "/var/lib/mysql/data"
426
+ else:
427
+ internal_port = "27017"
428
+ image = "mongo:6.0"
429
+ vol_path = "/data/db"
430
+
431
+ env = {}
432
+ if is_postgres:
433
+ env = {
434
+ "POSTGRES_USER": user,
435
+ "POSTGRES_PASSWORD": password,
436
+ "POSTGRES_DB": name,
437
+ }
438
+ elif is_mysql:
439
+ env = {
440
+ "MYSQL_ROOT_PASSWORD": password,
441
+ "MYSQL_DATABASE": name,
442
+ "MYSQL_USER": user,
443
+ "MYSQL_PASSWORD": password,
444
+ }
445
+ else:
446
+ env = {
447
+ "MONGO_INITDB_ROOT_USERNAME": user,
448
+ "MONGO_INITDB_ROOT_PASSWORD": password,
449
+ "MONGO_INITDB_DATABASE": name,
450
+ }
451
+
452
+ return {
453
+ "type": db_type,
454
+ "port": port,
455
+ "internal_port": internal_port,
456
+ "name": name,
457
+ "user": user,
458
+ "password": password,
459
+ "service_name": f"{name}-db",
460
+ "image": image,
461
+ "volume_name": f"{name}_data",
462
+ "volume_path": vol_path,
463
+ "env": env,
464
+ }
465
+
466
+
467
+ def _dockerfile_content(env: Environment, project: DockerProject) -> str:
468
+ if project.kind == "spring":
469
+ return env.get_template("spring/Dockerfile.j2").render(project=project)
470
+ if project.kind == "nest":
471
+ return env.get_template("nestjs/Dockerfile.j2").render(project=project)
472
+ if project.kind == "nodejs":
473
+ return env.get_template("nodejs/Dockerfile.j2").render(project=project)
474
+ if project.kind == "nextjs":
475
+ return env.get_template("nextjs/Dockerfile.j2").render(project=project)
476
+ if project.kind == "fastapi":
477
+ return env.get_template("fastapi/Dockerfile.j2").render(project=project)
478
+ if project.kind == "django":
479
+ return env.get_template("django/Dockerfile.j2").render(project=project)
480
+ if project.kind == "svelte":
481
+ return env.get_template("svelte/Dockerfile.j2").render(project=project)
482
+ if project.kind == "go":
483
+ return env.get_template("go/Dockerfile.j2").render(project=project)
484
+ return env.get_template("frontend/Dockerfile.j2").render(project=project)
485
+
486
+
487
+ def _write_file(path: Path, content: str, *, force: bool, dry_run: bool) -> FileOperation:
488
+ content = content.rstrip() + "\n"
489
+ exists = path.exists()
490
+
491
+ if exists and not force:
492
+ return FileOperation(path=path, action="skipped")
493
+
494
+ if dry_run:
495
+ action = "would_overwrite" if exists else "would_create"
496
+ return FileOperation(path=path, action=action)
497
+
498
+ path.parent.mkdir(parents=True, exist_ok=True)
499
+ path.write_text(content, encoding="utf-8")
500
+ return FileOperation(path=path, action="overwritten" if exists else "created")
501
+
502
+
503
+ def _template_environment() -> Environment:
504
+ template_dir = Path(__file__).resolve().parent.parent / "templates" / "docker"
505
+ return Environment(
506
+ loader=FileSystemLoader(str(template_dir)),
507
+ trim_blocks=True,
508
+ lstrip_blocks=True,
509
+ keep_trailing_newline=True,
510
+ )
511
+
512
+
513
+ def _project_name(kind: str, project_path: Path) -> str:
514
+ if kind == "spring":
515
+ return _spring_artifact_id(project_path) or project_path.name
516
+ if kind == "angular":
517
+ return (
518
+ _angular_output_name(project_path) or _package_name(project_path) or project_path.name
519
+ )
520
+ return _package_name(project_path) or project_path.name
521
+
522
+
523
+ def _unique_service_name(raw_name: str, used_names: set[str], fallback: str) -> str:
524
+ base_name = sanitize_service_name(raw_name, fallback=fallback)
525
+ if base_name not in used_names:
526
+ return base_name
527
+
528
+ index = 2
529
+ while f"{base_name}-{index}" in used_names:
530
+ index += 1
531
+ return f"{base_name}-{index}"
532
+
533
+
534
+ def _relative_context(root: Path, project_path: Path) -> str:
535
+ try:
536
+ relative = project_path.relative_to(root)
537
+ except ValueError:
538
+ relative = Path(os.path.relpath(project_path, root))
539
+
540
+ if str(relative) == ".":
541
+ return "."
542
+ return f"./{relative.as_posix()}"
543
+
544
+
545
+ def _spring_artifact_id(project_path: Path) -> Optional[str]:
546
+ pom_path = project_path / "pom.xml"
547
+ if not pom_path.exists():
548
+ return None
549
+
550
+ try:
551
+ root = ET.parse(pom_path).getroot()
552
+ except ET.ParseError:
553
+ return None
554
+
555
+ for child in root:
556
+ if _local_name(child.tag) == "artifactId" and child.text:
557
+ return child.text.strip()
558
+ return None
559
+
560
+
561
+ def _spring_java_version(project_path: Path) -> str:
562
+ pom_path = project_path / "pom.xml"
563
+ if not pom_path.exists():
564
+ return "17"
565
+
566
+ content = pom_path.read_text(encoding="utf-8", errors="ignore")
567
+ match = re.search(r"<java\.version>\s*([^<\s]+)\s*</java\.version>", content)
568
+ if match:
569
+ version = match.group(1)
570
+ major = re.match(r"\d+", version)
571
+ if major:
572
+ return major.group(0)
573
+ return "17"
574
+
575
+
576
+ def _angular_output_name(project_path: Path) -> Optional[str]:
577
+ angular_json = project_path / "angular.json"
578
+ if not angular_json.exists():
579
+ return None
580
+
581
+ try:
582
+ config = json.loads(angular_json.read_text(encoding="utf-8"))
583
+ projects = config.get("projects", {})
584
+ except (json.JSONDecodeError, OSError):
585
+ return None
586
+
587
+ if isinstance(projects, dict) and projects:
588
+ return str(next(iter(projects.keys())))
589
+ return None
590
+
591
+
592
+ def _package_name(project_path: Path) -> Optional[str]:
593
+ package_json = _read_package_json(project_path)
594
+ name = package_json.get("name")
595
+ return str(name) if name else None
596
+
597
+
598
+ def _node_version(project_path: Path, kind: str) -> str:
599
+ package_json = _read_package_json(project_path)
600
+ engines = package_json.get("engines", {})
601
+ node_range = str(engines.get("node", "")) if isinstance(engines, dict) else ""
602
+
603
+ engine_major = _highest_supported_node_major(node_range)
604
+ if engine_major:
605
+ return engine_major
606
+
607
+ if kind == "angular":
608
+ angular_major = _angular_major(package_json)
609
+ if angular_major >= 20:
610
+ return "22"
611
+ if angular_major >= 17:
612
+ return "20"
613
+ return "18"
614
+
615
+ if kind in ["nest", "nodejs", "nextjs", "svelte"]:
616
+ return "20"
617
+
618
+ return "22"
619
+
620
+
621
+ def _read_package_json(project_path: Path) -> dict[str, Any]:
622
+ package_path = project_path / "package.json"
623
+ if not package_path.exists():
624
+ return {}
625
+
626
+ try:
627
+ content = json.loads(package_path.read_text(encoding="utf-8"))
628
+ except (json.JSONDecodeError, OSError):
629
+ return {}
630
+
631
+ return content if isinstance(content, dict) else {}
632
+
633
+
634
+ def _highest_supported_node_major(node_range: str) -> Optional[str]:
635
+ supported = {18, 20, 22, 24}
636
+ majors = {int(value) for value in re.findall(r"(?<!\.)\b(18|20|22|24)\b", node_range)}
637
+ usable = sorted(majors & supported)
638
+ return str(usable[-1]) if usable else None
639
+
640
+
641
+ def _angular_major(package_json: dict[str, Any]) -> int:
642
+ sections = ["dependencies", "devDependencies"]
643
+ for section in sections:
644
+ dependencies = package_json.get(section, {})
645
+ if not isinstance(dependencies, dict):
646
+ continue
647
+ version = dependencies.get("@angular/core") or dependencies.get("@angular/cli")
648
+ if version:
649
+ match = re.search(r"(\d+)", str(version))
650
+ if match:
651
+ return int(match.group(1))
652
+ return 20
653
+
654
+
655
+ def _local_name(tag: str) -> str:
656
+ return tag.rsplit("}", 1)[-1]