quoll-cli 0.2.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 (191) hide show
  1. quoll/__init__.py +0 -0
  2. quoll/cd/__init__.py +1 -0
  3. quoll/cd/config.py +75 -0
  4. quoll/ci/__init__.py +0 -0
  5. quoll/ci/config.py +213 -0
  6. quoll/ci/custom.py +32 -0
  7. quoll/ci/detect.py +139 -0
  8. quoll/ci/steps.py +85 -0
  9. quoll/ci/tools.py +104 -0
  10. quoll/commands/__init__.py +0 -0
  11. quoll/commands/_common.py +145 -0
  12. quoll/commands/_workspace.py +83 -0
  13. quoll/commands/add.py +104 -0
  14. quoll/commands/add_secret.py +75 -0
  15. quoll/commands/cd.py +214 -0
  16. quoll/commands/ci.py +521 -0
  17. quoll/commands/docker.py +244 -0
  18. quoll/commands/k8s.py +211 -0
  19. quoll/commands/new.py +337 -0
  20. quoll/commands/remove.py +79 -0
  21. quoll/config/__init__.py +0 -0
  22. quoll/config/quoll_toml.py +196 -0
  23. quoll/config/settings.py +22 -0
  24. quoll/config/store.py +39 -0
  25. quoll/console.py +3 -0
  26. quoll/docker/__init__.py +1 -0
  27. quoll/docker/config.py +64 -0
  28. quoll/docker/detect.py +41 -0
  29. quoll/docker/render.py +162 -0
  30. quoll/files.py +64 -0
  31. quoll/k8s/__init__.py +0 -0
  32. quoll/k8s/chart/templates/_helpers.tpl +38 -0
  33. quoll/k8s/chart/templates/deployment.yaml +93 -0
  34. quoll/k8s/chart/templates/hpa.yaml +24 -0
  35. quoll/k8s/chart/templates/ingress.yaml +35 -0
  36. quoll/k8s/chart/templates/pdb.yaml +16 -0
  37. quoll/k8s/chart/templates/service.yaml +18 -0
  38. quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
  39. quoll/k8s/ci.py +39 -0
  40. quoll/k8s/config.py +97 -0
  41. quoll/k8s/deploy.py +22 -0
  42. quoll/k8s/detect.py +32 -0
  43. quoll/k8s/render.py +94 -0
  44. quoll/languages/__init__.py +121 -0
  45. quoll/languages/base.py +350 -0
  46. quoll/languages/cpp/Dockerfile.j2 +37 -0
  47. quoll/languages/cpp/__init__.py +17 -0
  48. quoll/languages/cpp/ci.py +192 -0
  49. quoll/languages/cpp/detect.py +84 -0
  50. quoll/languages/cpp/docker.py +47 -0
  51. quoll/languages/dotnet/Dockerfile.j2 +21 -0
  52. quoll/languages/dotnet/__init__.py +17 -0
  53. quoll/languages/dotnet/ci.py +88 -0
  54. quoll/languages/dotnet/detect.py +53 -0
  55. quoll/languages/dotnet/docker.py +47 -0
  56. quoll/languages/go/Dockerfile.j2 +25 -0
  57. quoll/languages/go/__init__.py +17 -0
  58. quoll/languages/go/ci.py +126 -0
  59. quoll/languages/go/detect.py +30 -0
  60. quoll/languages/go/docker.py +40 -0
  61. quoll/languages/java/Dockerfile.j2 +28 -0
  62. quoll/languages/java/__init__.py +20 -0
  63. quoll/languages/java/ci.py +163 -0
  64. quoll/languages/java/detect.py +77 -0
  65. quoll/languages/java/docker.py +45 -0
  66. quoll/languages/java/frameworks/__init__.py +1 -0
  67. quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
  68. quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
  69. quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
  70. quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
  71. quoll/languages/java/frameworks/spring_boot/services.py +24 -0
  72. quoll/languages/node/Dockerfile.j2 +98 -0
  73. quoll/languages/node/__init__.py +29 -0
  74. quoll/languages/node/ci.py +200 -0
  75. quoll/languages/node/detect.py +164 -0
  76. quoll/languages/node/docker.py +141 -0
  77. quoll/languages/node/frameworks/__init__.py +1 -0
  78. quoll/languages/node/frameworks/angular/__init__.py +18 -0
  79. quoll/languages/node/frameworks/angular/ci.py +34 -0
  80. quoll/languages/node/frameworks/angular/detect.py +20 -0
  81. quoll/languages/node/frameworks/angular/docker.py +31 -0
  82. quoll/languages/node/frameworks/angular/starter.py +74 -0
  83. quoll/languages/node/frameworks/astro/__init__.py +10 -0
  84. quoll/languages/node/frameworks/astro/ci.py +17 -0
  85. quoll/languages/node/frameworks/astro/detect.py +14 -0
  86. quoll/languages/node/frameworks/astro/docker.py +17 -0
  87. quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
  88. quoll/languages/node/frameworks/nestjs/detect.py +9 -0
  89. quoll/languages/node/frameworks/nestjs/docker.py +10 -0
  90. quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
  91. quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
  92. quoll/languages/node/frameworks/nextjs/ci.py +29 -0
  93. quoll/languages/node/frameworks/nextjs/detect.py +13 -0
  94. quoll/languages/node/frameworks/nextjs/docker.py +41 -0
  95. quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
  96. quoll/languages/node/frameworks/nuxt/ci.py +17 -0
  97. quoll/languages/node/frameworks/nuxt/detect.py +17 -0
  98. quoll/languages/node/frameworks/nuxt/docker.py +24 -0
  99. quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
  100. quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
  101. quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
  102. quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
  103. quoll/languages/node/starter.py +42 -0
  104. quoll/languages/node/templates/express/README.md +11 -0
  105. quoll/languages/node/templates/express/env.example +2 -0
  106. quoll/languages/node/templates/express/eslint.config.js +15 -0
  107. quoll/languages/node/templates/express/gitignore +4 -0
  108. quoll/languages/node/templates/express/nvmrc +1 -0
  109. quoll/languages/node/templates/express/package.json +16 -0
  110. quoll/languages/node/templates/express/prettierignore +6 -0
  111. quoll/languages/node/templates/express/src/app.ts +27 -0
  112. quoll/languages/node/templates/express/src/server.ts +11 -0
  113. quoll/languages/node/templates/express/test/app.test.ts +29 -0
  114. quoll/languages/node/templates/express/tsconfig.build.json +8 -0
  115. quoll/languages/node/templates/express/tsconfig.json +14 -0
  116. quoll/languages/node/templates/express/vitest.config.ts +12 -0
  117. quoll/languages/php/Dockerfile.j2 +40 -0
  118. quoll/languages/php/__init__.py +19 -0
  119. quoll/languages/php/ci.py +147 -0
  120. quoll/languages/php/detect.py +75 -0
  121. quoll/languages/php/docker.py +59 -0
  122. quoll/languages/php/frameworks/__init__.py +1 -0
  123. quoll/languages/php/frameworks/laravel/__init__.py +17 -0
  124. quoll/languages/php/frameworks/laravel/ci.py +24 -0
  125. quoll/languages/php/frameworks/laravel/detect.py +13 -0
  126. quoll/languages/php/frameworks/laravel/docker.py +29 -0
  127. quoll/languages/php/frameworks/laravel/services.py +25 -0
  128. quoll/languages/php/frameworks/symfony/__init__.py +15 -0
  129. quoll/languages/php/frameworks/symfony/ci.py +22 -0
  130. quoll/languages/php/frameworks/symfony/detect.py +17 -0
  131. quoll/languages/php/frameworks/symfony/docker.py +25 -0
  132. quoll/languages/python/Dockerfile.j2 +61 -0
  133. quoll/languages/python/__init__.py +19 -0
  134. quoll/languages/python/ci.py +157 -0
  135. quoll/languages/python/detect.py +175 -0
  136. quoll/languages/python/docker.py +80 -0
  137. quoll/languages/python/frameworks/__init__.py +1 -0
  138. quoll/languages/python/frameworks/django/__init__.py +16 -0
  139. quoll/languages/python/frameworks/django/ci.py +43 -0
  140. quoll/languages/python/frameworks/django/detect.py +17 -0
  141. quoll/languages/python/frameworks/django/docker.py +20 -0
  142. quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
  143. quoll/languages/python/frameworks/fastapi/detect.py +9 -0
  144. quoll/languages/python/frameworks/fastapi/docker.py +21 -0
  145. quoll/languages/python/frameworks/flask/__init__.py +7 -0
  146. quoll/languages/python/frameworks/flask/detect.py +9 -0
  147. quoll/languages/python/frameworks/flask/docker.py +22 -0
  148. quoll/languages/ruby/Dockerfile.j2 +45 -0
  149. quoll/languages/ruby/__init__.py +19 -0
  150. quoll/languages/ruby/ci.py +110 -0
  151. quoll/languages/ruby/detect.py +72 -0
  152. quoll/languages/ruby/docker.py +63 -0
  153. quoll/languages/ruby/frameworks/__init__.py +1 -0
  154. quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
  155. quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
  156. quoll/languages/ruby/frameworks/rails/ci.py +26 -0
  157. quoll/languages/ruby/frameworks/rails/detect.py +15 -0
  158. quoll/languages/ruby/frameworks/rails/docker.py +62 -0
  159. quoll/languages/ruby/frameworks/rails/services.py +10 -0
  160. quoll/languages/rust/Dockerfile.j2 +23 -0
  161. quoll/languages/rust/__init__.py +17 -0
  162. quoll/languages/rust/ci.py +125 -0
  163. quoll/languages/rust/detect.py +46 -0
  164. quoll/languages/rust/docker.py +38 -0
  165. quoll/main.py +50 -0
  166. quoll/platforms/__init__.py +62 -0
  167. quoll/platforms/base.py +103 -0
  168. quoll/platforms/github/__init__.py +4 -0
  169. quoll/platforms/github/actions.py +78 -0
  170. quoll/platforms/github/api.py +124 -0
  171. quoll/platforms/github/cd.py +265 -0
  172. quoll/platforms/github/ci.py +497 -0
  173. quoll/platforms/github/dependabot.yml.j2 +49 -0
  174. quoll/platforms/github/platform.py +52 -0
  175. quoll/platforms/github/workflow.py +74 -0
  176. quoll/platforms/gitlab/__init__.py +4 -0
  177. quoll/platforms/gitlab/api.py +158 -0
  178. quoll/platforms/gitlab/cd.py +226 -0
  179. quoll/platforms/gitlab/ci.py +400 -0
  180. quoll/platforms/gitlab/pipeline.py +136 -0
  181. quoll/platforms/gitlab/platform.py +54 -0
  182. quoll/project.py +60 -0
  183. quoll/services.py +117 -0
  184. quoll/starters.py +117 -0
  185. quoll/workspace.py +232 -0
  186. quoll/yaml_dump.py +137 -0
  187. quoll_cli-0.2.0.dist-info/METADATA +641 -0
  188. quoll_cli-0.2.0.dist-info/RECORD +191 -0
  189. quoll_cli-0.2.0.dist-info/WHEEL +4 -0
  190. quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
  191. quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
quoll/commands/new.py ADDED
@@ -0,0 +1,337 @@
1
+ """`quoll new`: a project from nothing — a front-end, a back-end, services, or several of them in one repository
2
+ (front/, back/, services/<name>/) — with its CI, Dockerfiles and, optionally, its delivery and its GitHub/GitLab
3
+ repository."""
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Callable, Dict, List, Optional, Tuple
11
+
12
+ import questionary
13
+ import typer
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn
15
+
16
+ from quoll import languages, platforms
17
+ from quoll.commands import cd as cd_command
18
+ from quoll.commands import ci as ci_command
19
+ from quoll.commands import docker as docker_command
20
+ from quoll.commands import k8s as k8s_command
21
+ from quoll.commands._common import ask, check_platform, ensure_token, platform_option
22
+ from quoll.console import console
23
+ from quoll.docker.config import save_config as save_docker_config
24
+ from quoll.languages.base import Starter, StarterOptions
25
+ from quoll.platforms import PlatformError
26
+ from quoll.starters import StarterError
27
+ from quoll.workspace import member_name
28
+
29
+ # Where each part goes when the repository holds several.
30
+ DIRECTORIES = {"front": "front", "back": "back"}
31
+ SERVICES_DIRECTORY = "services"
32
+
33
+ @dataclass
34
+ class Part:
35
+ """A project of the repository to create: where, from which starter."""
36
+
37
+ directory: str
38
+ starter: Starter
39
+ version: str
40
+ package: str
41
+
42
+ @property
43
+ def label(self) -> str:
44
+ return self.starter.label + (f" {self.version}" if self.version else "")
45
+
46
+
47
+ _GITIGNORE = """# Each project ignores its own files (front/.gitignore, back/.gitignore...).
48
+ .env
49
+ .DS_Store
50
+ .idea/
51
+ .vscode/
52
+ """
53
+
54
+
55
+ def new(
56
+ name: Optional[str] = typer.Argument(None, help="Project name: lowercase letters, digits and dashes."),
57
+ parent: Path = typer.Option(Path("."), "--dir", "-d", help="Where to create the project's directory."),
58
+ front: Optional[str] = typer.Option(
59
+ None, help="Front-end starter, e.g. angular, or angular@18 for a version ('none' for no front-end)."
60
+ ),
61
+ back: Optional[str] = typer.Option(
62
+ None, help="Back-end starter, e.g. express, or express@4 ('none' for no back-end)."
63
+ ),
64
+ services: List[str] = typer.Option(
65
+ [], "--service", help="A service in services/<name>: NAME=STARTER[@VERSION], e.g. orders=express (repeatable)."
66
+ ),
67
+ node: Optional[str] = typer.Option(None, "--node", help="Node.js version of the Node-based parts, e.g. 20."),
68
+ platform: Optional[str] = platform_option(),
69
+ delivery: Optional[bool] = typer.Option(None, "--cd/--no-cd", help="Also publish the images (quoll cd)."),
70
+ kubernetes: Optional[bool] = typer.Option(
71
+ None, "--k8s/--no-k8s", help="A Helm chart per project, deployed by the delivery (quoll k8s)."
72
+ ),
73
+ remote: Optional[bool] = typer.Option(
74
+ None, "--remote/--local", help="Create the GitHub/GitLab repository, push, protect the default branch."
75
+ ),
76
+ private: bool = typer.Option(True, "--private/--public", help="Visibility of the created repository."),
77
+ yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask: use the options and defaults."),
78
+ ) -> None:
79
+ """Create a project from nothing: front/, back/, services/<name>/ (or one of them), CI, Dockerfiles, delivery,
80
+ repository."""
81
+ check_platform(platform)
82
+ console.print("\n[bold]quoll new[/bold]\n")
83
+
84
+ if name is None:
85
+ name = "app" if yes else ask(questionary.text("Project name", validate=_valid_name))
86
+ if _valid_name(name) is not True:
87
+ console.print(f"[red]Error:[/red] {_valid_name(name)}")
88
+ raise typer.Exit(1)
89
+ root = parent.resolve() / name
90
+ if root.exists() and any(root.iterdir()):
91
+ console.print(f"[red]Error:[/red] {root} exists and isn't empty.")
92
+ raise typer.Exit(1)
93
+
94
+ picked = [p for p in (_pick("front", front, yes), _pick("back", back, yes)) if p]
95
+ picked_services = [parse_service(spec) for spec in services]
96
+ if not picked and not picked_services:
97
+ console.print("[red]Error:[/red] nothing to create: choose a front-end, a back-end or services.")
98
+ raise typer.Exit(1)
99
+ several = len(picked) + len(picked_services) > 1
100
+ parts = [
101
+ Part(DIRECTORIES[role] if several else ".", starter, version, f"{name}-{role}" if several else name)
102
+ for role, starter, version in picked
103
+ ] + [Part(f"{SERVICES_DIRECTORY}/{n}" if several else ".", s, v, n) for n, s, v in picked_services]
104
+ if node is None and not yes and any(p.starter.runtime == "node" for p in parts):
105
+ node = ask(questionary.select(
106
+ "Node.js version", choices=["24", "22", "20"], default=parts[0].starter.runtime_default or "24"
107
+ ))
108
+ runtimes = {"node": node or ""}
109
+ if platform is None:
110
+ platform = "github" if yes else ask(questionary.select(
111
+ "Hosting platform",
112
+ choices=[questionary.Choice(platforms.get(n).label, n) for n in platforms.NAMES],
113
+ ))
114
+ if delivery is None:
115
+ delivery = not yes and ask(questionary.confirm("Publish the images (build, scan, push)?", default=True))
116
+ if kubernetes is None:
117
+ kubernetes = not yes and ask(questionary.confirm(
118
+ "Deploy to Kubernetes (Helm charts; staging on each push, production on v* tags)?", default=delivery
119
+ ))
120
+ if remote is None:
121
+ label = platforms.get(platform).label
122
+ remote = not yes and ask(questionary.confirm(f"Create the {label} repository and push?", default=True))
123
+
124
+ # One part: the repository is the project. Several: a workspace, one directory each.
125
+ notes: List[str] = []
126
+ root.mkdir(parents=True, exist_ok=True)
127
+ target = platforms.get(platform)
128
+ repo = None
129
+
130
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console, transient=False) as progress:
131
+ for part in parts:
132
+ notes += create_part(root, name, part, runtimes, progress)
133
+
134
+ if several:
135
+ (root / ".gitignore").write_text(_GITIGNORE)
136
+ (root / "README.md").write_text(_readme(name, parts))
137
+ _git(root, "init", "-q", "-b", "main")
138
+
139
+ if remote:
140
+ task = progress.add_task(f"Creating the {target.label} repository...")
141
+ if not ensure_token(platform):
142
+ progress.stop()
143
+ console.print(f"[red]Error:[/red] no {target.label} token.")
144
+ raise typer.Exit(1)
145
+ try:
146
+ repo = target.api().create_repo(name, private=private)
147
+ except PlatformError as e:
148
+ progress.stop()
149
+ console.print(f"[red]{target.label} error:[/red] {e}")
150
+ raise typer.Exit(1)
151
+ # Before the CI and the delivery: their defaults (image names...) come from the remote.
152
+ _git(root, "remote", "add", "origin", repo.clone_url)
153
+ progress.update(task, description=f"[green]✓[/green] {target.label} repository: {repo.web_url}")
154
+
155
+ task = progress.add_task("Generating the CI, the Dockerfiles" + (" and the delivery..." if delivery else "..."))
156
+ # Docker first: the CI then builds and scans the images.
157
+ steps: List[Tuple[str, Callable[[], None]]] = [("Docker", docker_init(root / p.directory)) for p in parts]
158
+ by_role = {p.starter.role: p for p in parts if p.directory in DIRECTORIES.values()}
159
+ if "front" in by_role and "back" in by_role:
160
+ steps.append(("Docker", proxy_api(root, by_role["front"], by_role["back"])))
161
+ steps.append(
162
+ ("CI", lambda: ci_command.init(project_dir=root, yes=True, force=True, platform=platform, local=True))
163
+ )
164
+ if several:
165
+ # Now a workspace: the compose.yaml running every project.
166
+ steps.append(("Docker", docker_init(root)))
167
+ if kubernetes:
168
+ # Before the delivery: it deploys the charts.
169
+ steps.append(("Kubernetes", lambda: k8s_command.init(project_dir=root, yes=True, force=False)))
170
+ if delivery:
171
+ steps.append(("CD", lambda: cd_command.init(
172
+ project_dir=root, yes=True, force=False, platform=platform, registry=None, image=None
173
+ )))
174
+ for label, run in steps:
175
+ quiet(label, run, progress)
176
+ done = "CI, Dockerfiles" + (", Helm charts" if kubernetes else "") + (", delivery" if delivery else "")
177
+ progress.update(task, description=f"[green]✓[/green] {done}")
178
+
179
+ _git(root, "add", "-A")
180
+ committed = _git(root, "commit", "-q", "-m", f"chore: create {name} with quoll", check=False)
181
+ if remote and repo and committed:
182
+ task = progress.add_task("Pushing and protecting the default branch...")
183
+ pushed = _git(root, "push", "-q", "-u", "origin", "main", check=False)
184
+ warnings = []
185
+ if pushed:
186
+ try:
187
+ api = target.api()
188
+ warnings = api.protect_branch(repo.path, "main", required_checks=target.required_checks)
189
+ if target.schedules_via_api:
190
+ api.ensure_schedule(repo.path, "main", "0 6 * * 1")
191
+ except PlatformError as e:
192
+ warnings.append(str(e))
193
+ progress.update(task, description="[green]✓[/green] Pushed, main protected" if pushed
194
+ else "[yellow]![/yellow] Push failed: push the main branch yourself")
195
+ for warning in warnings:
196
+ progress.console.print(f"[yellow]![/yellow] {warning}")
197
+
198
+ for note in notes:
199
+ console.print(f"[yellow]![/yellow] {note}")
200
+ if not committed:
201
+ console.print("[yellow]![/yellow] Nothing committed (is git's user.name / user.email set?): commit yourself.")
202
+ _summary(root, name, parts, repo)
203
+
204
+
205
+ def create_part(root: Path, name: str, part: Part, runtimes: Dict[str, str], progress: Progress) -> List[str]:
206
+ """Run `part`'s starter in its directory; the starter's notes."""
207
+ where = root / part.directory
208
+ where.mkdir(parents=True, exist_ok=True)
209
+ runtime = runtimes.get(part.starter.runtime, "")
210
+ details = f" (Node.js {runtime})" if runtime else ""
211
+ shown = f"{name}/" if part.directory == "." else f"{name}/{part.directory}/"
212
+ task = progress.add_task(f"Creating {part.label}{details} in {shown}...")
213
+ try:
214
+ notes = part.starter.create(where, StarterOptions(part.package, part.version, runtime)) or []
215
+ except StarterError as e:
216
+ progress.stop()
217
+ console.print(f"\n[red]Error:[/red] {e}")
218
+ raise typer.Exit(1)
219
+ progress.update(task, description=f"[green]✓[/green] {part.label}{details} in {shown}")
220
+ return notes
221
+
222
+
223
+ def docker_init(directory: Path) -> Callable[[], None]:
224
+ return lambda: docker_command.init(project_dir=directory, yes=True, force=False, command="")
225
+
226
+
227
+ def proxy_api(root: Path, front: Part, back: Part) -> Callable[[], None]:
228
+ """The front-end calls the back-end at /api/, on its own origin (no CORS): its dev server forwards there, and
229
+ so does nginx in its image, to BACK_URL (set by the workspace's compose.yaml)."""
230
+
231
+ def run() -> None:
232
+ docker = docker_command._load(root / front.directory)
233
+ if not docker.static: # a server-rendered front-end calls the API itself
234
+ return
235
+ if front.starter.proxy:
236
+ port = docker_command._load(root / back.directory).port
237
+ front.starter.proxy(root / front.directory, "/api", f"http://localhost:{port}")
238
+ docker.proxy = {"/api/": f"{member_name(back.directory).upper().replace('-', '_')}_URL"}
239
+ save_docker_config(root / front.directory, docker)
240
+ docker_command.update(project_dir=root / front.directory, yes=True, force=False)
241
+
242
+ return run
243
+
244
+
245
+ def quiet(label: str, run: Callable[[], None], progress: Progress) -> None:
246
+ """Run another quoll command; its own report would drown this one: shown only when something goes wrong."""
247
+ with console.capture() as captured:
248
+ try:
249
+ run()
250
+ failed = False
251
+ except typer.Exit as e:
252
+ failed = bool(e.exit_code)
253
+ if failed:
254
+ progress.stop()
255
+ console.print(captured.get())
256
+ console.print(f"[red]Error:[/red] quoll {label.lower()} failed.")
257
+ raise typer.Exit(1)
258
+
259
+
260
+ def parse_service(spec: str) -> Tuple[str, Starter, str]:
261
+ """NAME=STARTER[@VERSION] -> (name, starter, version)."""
262
+ service, _, choice = spec.partition("=")
263
+ if _valid_name(service) is not True or not choice:
264
+ console.print(f"[red]Error:[/red] --service {spec}: expected NAME=STARTER, e.g. orders=express")
265
+ raise typer.Exit(1)
266
+ starter_name, _, version = choice.partition("@")
267
+ by_name = {s.name: s for s in languages.starters()}
268
+ if starter_name not in by_name:
269
+ console.print(f"[red]Error:[/red] unknown starter '{starter_name}' (expected one of: {', '.join(by_name)}).")
270
+ raise typer.Exit(1)
271
+ return service, by_name[starter_name], version
272
+
273
+
274
+ def _pick(role: str, choice: Optional[str], yes: bool) -> Optional[Tuple[str, Starter, str]]:
275
+ """(role, starter, version) for `role`: from `choice` ("angular", "angular@18"), else asked."""
276
+ starters = languages.starters(role)
277
+ by_name = {s.name: s for s in starters}
278
+ asked = choice is None and not yes
279
+ if asked:
280
+ choice = ask(questionary.select(
281
+ f"{role.capitalize()}-end",
282
+ choices=[*(questionary.Choice(s.label, s.name) for s in starters), questionary.Choice("None", "none")],
283
+ ))
284
+ if choice in (None, "none"):
285
+ return None
286
+ starter_name, _, version = choice.partition("@")
287
+ if starter_name not in by_name:
288
+ console.print(f"[red]Error:[/red] unknown {role}-end '{starter_name}' (expected one of: {', '.join(by_name)}).")
289
+ raise typer.Exit(1)
290
+ starter = by_name[starter_name]
291
+ if asked:
292
+ version = ask(questionary.text(f"{starter.label} version (Enter: the latest)", default=""))
293
+ return role, starter, version.strip()
294
+
295
+
296
+ def _valid_name(value: str):
297
+ if re.fullmatch(r"[a-z][a-z0-9-]*", value or ""):
298
+ return True
299
+ return "lowercase letters, digits and dashes, starting with a letter"
300
+
301
+
302
+ def _git(root: Path, *args: str, check: bool = True) -> bool:
303
+ result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
304
+ if result.returncode != 0 and check:
305
+ console.print(f"[red]Error:[/red] git {args[0]} failed: {result.stderr.strip()}")
306
+ raise typer.Exit(1)
307
+ return result.returncode == 0
308
+
309
+
310
+ def _readme(name: str, parts: List[Part]) -> str:
311
+ listed = "\n".join(f"- [`{p.directory}/`]({p.directory}/): {p.label}" for p in parts)
312
+ return f"""# {name}
313
+
314
+ {listed}
315
+
316
+ `docker compose up --build` runs them all, with their databases: each reaches the others at `<NAME>_URL`
317
+ (e.g. `BACK_URL=http://back:3000`). Each project also has its own README, Dockerfile and `compose.yaml`.
318
+
319
+ The CI, the Docker files and the delivery are generated by [quoll](https://github.com/theo-mrn/Quoll)
320
+ from the `quoll.toml` files: change them, then run `quoll ci update` (or `quoll docker update`, `quoll cd update`).
321
+ Add a service with `quoll add service <name>=express` (it lands in `services/<name>/`).
322
+ """
323
+
324
+
325
+ def _summary(root: Path, name: str, parts: List[Part], repo) -> None:
326
+ console.print(f"\n[bold green]✓ {name} is ready:[/bold green] {root}")
327
+ if repo:
328
+ console.print(f" {repo.web_url}")
329
+ console.print("\n[dim]Next steps:[/dim]")
330
+ for part in parts:
331
+ where = name if part.directory == "." else f"{name}/{part.directory}"
332
+ run = "npm start" if part.starter.role == "front" else "npm run dev"
333
+ console.print(f" cd {where} && {run}")
334
+ if len(parts) > 1:
335
+ console.print(f" cd {name} && docker compose up --build # everything, with the databases")
336
+ else:
337
+ console.print(" docker compose up --build")
@@ -0,0 +1,79 @@
1
+ """`quoll remove service`: take a project out of a workspace, and out of its CI, compose.yaml and delivery."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import questionary
9
+ import typer
10
+
11
+ from quoll import platforms
12
+ from quoll.ci.detect import configured_platform
13
+ from quoll.commands._common import DirOption, ask, resolve_dir
14
+ from quoll.commands._workspace import refresh, remove_empty_parents, snapshot
15
+ from quoll.config import quoll_toml
16
+ from quoll.config.quoll_toml import ConfigError
17
+ from quoll.console import console
18
+ from quoll.workspace import WorkspaceConfig, find_root, load_workspace, member_name
19
+
20
+ app = typer.Typer(help="Remove a project from a workspace.", no_args_is_help=True)
21
+
22
+
23
+ @app.command()
24
+ def service(
25
+ name: str = typer.Argument(..., help="The project's name or directory, e.g. billing or services/billing."),
26
+ project_dir: Path = DirOption,
27
+ keep_files: bool = typer.Option(
28
+ False, "--keep-files", help="Leave its directory in place: only take it out of the workspace."
29
+ ),
30
+ yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask before deleting its directory."),
31
+ ) -> None:
32
+ """Delete a project of the workspace, and remove it from the pipeline, compose.yaml and delivery."""
33
+ root = find_root(resolve_dir(project_dir))
34
+ try:
35
+ workspace = load_workspace(root)
36
+ except ConfigError as e:
37
+ console.print(f"[red]Error:[/red] {e}")
38
+ raise typer.Exit(1)
39
+ if workspace is None:
40
+ console.print("[red]Error:[/red] not a workspace: there is no project to remove.")
41
+ raise typer.Exit(1)
42
+ directory = next((m for m in workspace.members if name.strip("/") in (m, member_name(m))), None)
43
+ if directory is None:
44
+ console.print(f"[red]Error:[/red] no project named {name} (members: {', '.join(workspace.members)}).")
45
+ raise typer.Exit(1)
46
+ if len(workspace.members) == 1:
47
+ console.print(f"[red]Error:[/red] {directory} is the workspace's only project.")
48
+ raise typer.Exit(1)
49
+
50
+ delete = not keep_files and (root / directory).exists()
51
+ if delete and _uncommitted(root, directory):
52
+ console.print(f"[red]Error:[/red] {directory} has uncommitted changes: commit them first (git keeps the "
53
+ "project in its history), or use --keep-files.")
54
+ raise typer.Exit(1)
55
+ if delete and not yes and not ask(questionary.confirm(f"Delete {directory}/ and everything in it?", default=False)):
56
+ raise typer.Exit(1)
57
+
58
+ platform = configured_platform(root) or platforms.detect(root, platforms.remote(root))
59
+ before = snapshot(root, platform)
60
+ members = [m for m in workspace.members if m != directory]
61
+ quoll_toml.save_section(root, "workspace", WorkspaceConfig(members=members))
62
+ if delete:
63
+ shutil.rmtree(root / directory)
64
+ remove_empty_parents(root, root / directory)
65
+ console.print(f"[green]✓[/green] {directory} " + ("deleted" if delete else "taken out of the workspace"))
66
+ refresh(root, platform, before, f"remove {member_name(directory)} from it yourself")
67
+
68
+ variable = f"{member_name(directory).upper().replace('-', '_')}_URL"
69
+ console.print(f"\n[bold green]✓ {member_name(directory)}[/bold green] removed. The others no longer get "
70
+ f"[bold]{variable}[/bold]: check that their code doesn't use it.")
71
+
72
+
73
+ def _uncommitted(root: Path, directory: str) -> bool:
74
+ """True when git would lose changes to `directory` (modified or untracked files)."""
75
+ done = subprocess.run(
76
+ ["git", "status", "--porcelain", "--", directory], cwd=root, capture_output=True, text=True
77
+ )
78
+ return done.returncode == 0 and bool(done.stdout.strip())
79
+
File without changes
@@ -0,0 +1,196 @@
1
+ """quoll.toml: a shared [project] section plus one section per feature ([ci], [docker], [cd]).
2
+
3
+ Each feature model declares the [project] fields it uses (`shared_fields`; all of them for `ProjectConfig`
4
+ subclasses), so in memory it carries them too; on disk they live once, in [project], so the CI, the image and
5
+ the delivery can't disagree about the stack or the platform.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Callable, ClassVar, Dict, List, Optional, Tuple, Type, TypeVar, Union
11
+
12
+ try:
13
+ import tomllib
14
+ except ImportError:
15
+ import tomli as tomllib # type: ignore[no-redef]
16
+ import tomli_w
17
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError, field_validator, model_validator
18
+
19
+ from quoll import languages, platforms
20
+ from quoll.services import DATABASE, SERVICES, environment
21
+
22
+ CONFIG_FILE = "quoll.toml"
23
+ PROJECT_SECTION = "project"
24
+
25
+
26
+ class ConfigError(Exception):
27
+ pass
28
+
29
+
30
+ class Section(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ # Fields stored in [project] and shared with the other sections.
34
+ shared_fields: ClassVar[Tuple[str, ...]] = ()
35
+ # Lists left out of quoll.toml while empty, so they can be written as [[section.field]] tables ("a.b" for a
36
+ # nested one).
37
+ omit_when_empty: ClassVar[Tuple[str, ...]] = ()
38
+
39
+
40
+ class PlatformField(BaseModel):
41
+ # Where the repository is hosted and the pipelines run: github | gitlab.
42
+ platform: str = "github"
43
+
44
+ @field_validator("platform")
45
+ @classmethod
46
+ def _known_platform(cls, value: str) -> str:
47
+ if value not in platforms.NAMES:
48
+ raise ValueError(f"unknown platform '{value}' (expected one of: {', '.join(platforms.NAMES)})")
49
+ return value
50
+
51
+
52
+ class ProjectConfig(Section, PlatformField):
53
+ # Where the project lives in the repository ("." alone; e.g. "backend" in a workspace). Not stored: it is
54
+ # where its quoll.toml is.
55
+ _directory: str = PrivateAttr(default=".")
56
+
57
+ # Name of a registered language plugin (see quoll/languages).
58
+ stack: str
59
+ # Name of one of the stack's frameworks, or empty.
60
+ framework: str = ""
61
+ package_manager: str
62
+ # Pin the package manager itself (pnpm, bun). Empty = read package.json "packageManager".
63
+ package_manager_version: str = ""
64
+ # Backing services: postgres | mysql | redis | mongodb. Run next to the CI tests and in compose.yaml.
65
+ services: List[str] = Field(default_factory=list)
66
+
67
+ @field_validator("stack")
68
+ @classmethod
69
+ def _known_stack(cls, value: str) -> str:
70
+ if value not in languages.names():
71
+ raise ValueError(f"unknown stack '{value}' (expected one of: {', '.join(languages.names())})")
72
+ return value
73
+
74
+ @field_validator("services")
75
+ @classmethod
76
+ def _known_services(cls, value: List[str]) -> List[str]:
77
+ unknown = [s for s in value if s not in SERVICES]
78
+ if unknown:
79
+ raise ValueError(f"unknown service '{unknown[0]}' (expected one of: {', '.join(SERVICES)})")
80
+ return value
81
+
82
+ @model_validator(mode="after")
83
+ def _check_project(self) -> "ProjectConfig":
84
+ lang = self.language
85
+ if self.framework and self.get_framework() is None:
86
+ known = [f.name for f in languages.frameworks(lang.name)]
87
+ raise ValueError(
88
+ f"unknown framework '{self.framework}' for {lang.name} (expected one of: {', '.join(known)})"
89
+ )
90
+ if self.package_manager not in lang.package_managers:
91
+ raise ValueError(
92
+ f"package_manager '{self.package_manager}' is not valid for {lang.name} "
93
+ f"(expected one of: {', '.join(lang.package_managers)})"
94
+ )
95
+ return self
96
+
97
+ @property
98
+ def language(self) -> languages.Language:
99
+ return languages.get(self.stack)
100
+
101
+ def get_framework(self) -> Optional[languages.Framework]:
102
+ return languages.get_framework(self.stack, self.framework) if self.framework else None
103
+
104
+ def service_environment(self, host: Union[str, Callable[[str], str]], database: str = DATABASE) -> Dict[str, str]:
105
+ """Variables pointing the app at its services on `host` (or `host(service)`), using `database`."""
106
+ framework = self.get_framework()
107
+ hooks = [self.language.service_env] + ([framework.service_env] if framework else [])
108
+ return environment(self.services, host, *hooks, database=database)
109
+
110
+ @property
111
+ def directory(self) -> str:
112
+ return self._directory
113
+
114
+ def in_directory(self, directory: str) -> "ProjectConfig":
115
+ """This config for a project living in `directory` of the repository."""
116
+ self._directory = directory.strip("/") or "."
117
+ return self
118
+
119
+ def path(self, name: str) -> str:
120
+ """`name`, relative to the project, as a path from the repository root."""
121
+ return name if self._directory == "." else f"{self._directory}/{name}"
122
+
123
+ @property
124
+ def target(self) -> str:
125
+ return f"{self.stack}/{self.framework}" if self.framework else self.stack
126
+
127
+
128
+ PROJECT_FIELDS = tuple(ProjectConfig.model_fields)
129
+ ProjectConfig.shared_fields = PROJECT_FIELDS
130
+
131
+ M = TypeVar("M", bound=Section)
132
+
133
+
134
+ def read(project_dir: Path) -> dict:
135
+ path = project_dir / CONFIG_FILE
136
+ if not path.exists():
137
+ return {}
138
+ try:
139
+ with open(path, "rb") as f:
140
+ return tomllib.load(f)
141
+ except tomllib.TOMLDecodeError as e:
142
+ raise ConfigError(f"{CONFIG_FILE} is not valid TOML: {e}") from e
143
+
144
+
145
+ def load_section(project_dir: Path, section: str, model: Type[M]) -> M:
146
+ data = read(project_dir)
147
+ if section not in data:
148
+ if not (project_dir / CONFIG_FILE).exists():
149
+ raise ConfigError(f"{CONFIG_FILE} not found in {project_dir}. Run `quoll {section} init` first.")
150
+ raise ConfigError(f"{CONFIG_FILE} has no [{section}] section. Run `quoll {section} init` first.")
151
+ merged = dict(data[section])
152
+ project = data.get(PROJECT_SECTION, {})
153
+ # Older files kept the project fields inside [ci]; [project] wins when both exist.
154
+ merged.update({k: v for k, v in project.items() if k in model.shared_fields})
155
+ try:
156
+ return model.model_validate(merged)
157
+ except ValidationError as e:
158
+ details = "\n".join(f" {_location(model, section, err['loc'])}{err['msg']}" for err in e.errors())
159
+ raise ConfigError(f"Invalid {CONFIG_FILE}:\n{details}") from e
160
+
161
+
162
+ def save_section(project_dir: Path, section: str, config: Section) -> Path:
163
+ """Write `section` and its shared fields in [project], preserving the rest of quoll.toml."""
164
+ data = read(project_dir)
165
+ values = config.model_dump()
166
+ for name in config.omit_when_empty:
167
+ *parents, key = name.split(".")
168
+ table = values
169
+ for parent in parents:
170
+ table = table.get(parent) or {}
171
+ if not table.get(key):
172
+ table.pop(key, None)
173
+ shared = {k: values.pop(k) for k in config.shared_fields}
174
+ if shared:
175
+ data[PROJECT_SECTION] = {**data.get(PROJECT_SECTION, {}), **shared}
176
+ data[section] = values
177
+ # [project] first, then the features in a stable order.
178
+ project = {PROJECT_SECTION: data.pop(PROJECT_SECTION)} if PROJECT_SECTION in data else {}
179
+ ordered = {**project, **dict(sorted(data.items()))}
180
+ path = project_dir / CONFIG_FILE
181
+ path.write_text(tomli_w.dumps(ordered))
182
+ return path
183
+
184
+
185
+ def remove_section(project_dir: Path, section: str) -> None:
186
+ """Drop `section` from quoll.toml ([project] stays: other sections may still use it)."""
187
+ data = read(project_dir)
188
+ data.pop(section, None)
189
+ (project_dir / CONFIG_FILE).write_text(tomli_w.dumps(data))
190
+
191
+
192
+ def _location(model: Type[Section], section: str, loc: tuple) -> str:
193
+ if not loc:
194
+ return ""
195
+ prefix = PROJECT_SECTION if loc[0] in model.shared_fields else section
196
+ return f"{prefix}.{'.'.join(str(p) for p in loc)}: "
@@ -0,0 +1,22 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ model_config = SettingsConfigDict(
6
+ env_prefix="QUOLL_",
7
+ env_file=".env",
8
+ extra="ignore",
9
+ frozen=False,
10
+ )
11
+
12
+ github_token: str = ""
13
+ github_org: str = ""
14
+ gitlab_token: str = ""
15
+ # gitlab.com or a self-managed instance.
16
+ gitlab_url: str = "https://gitlab.com"
17
+ # Group (or group/subgroup) where `quoll new --remote` creates GitLab projects; empty = the user's namespace.
18
+ gitlab_group: str = ""
19
+ default_branch: str = "main"
20
+
21
+
22
+ settings = Settings()
quoll/config/store.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ import tomllib
5
+ except ImportError:
6
+ import tomli as tomllib # type: ignore[no-redef]
7
+ from pathlib import Path
8
+
9
+ import tomli_w
10
+
11
+ _CONFIG_PATH = Path.home() / ".config" / "quoll" / "config.toml"
12
+ # Where tokens were stored before the tool was renamed; read when the new file doesn't exist yet.
13
+ _LEGACY_CONFIG_PATH = Path.home() / ".config" / "forge" / "config.toml"
14
+
15
+
16
+ def load_stored_token(platform: str = "github") -> str:
17
+ path = _CONFIG_PATH if _CONFIG_PATH.exists() else _LEGACY_CONFIG_PATH
18
+ if not path.exists():
19
+ return ""
20
+ try:
21
+ with open(path, "rb") as f:
22
+ data = tomllib.load(f)
23
+ return data.get(platform, {}).get("token", "")
24
+ except Exception:
25
+ return ""
26
+
27
+
28
+ def save_token(token: str, platform: str = "github") -> None:
29
+ _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
30
+ existing: dict = {}
31
+ if _CONFIG_PATH.exists():
32
+ try:
33
+ with open(_CONFIG_PATH, "rb") as f:
34
+ existing = tomllib.load(f)
35
+ except Exception:
36
+ pass
37
+ existing.setdefault(platform, {})["token"] = token
38
+ with open(_CONFIG_PATH, "wb") as f:
39
+ tomli_w.dump(existing, f)