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.
- quoll/__init__.py +0 -0
- quoll/cd/__init__.py +1 -0
- quoll/cd/config.py +75 -0
- quoll/ci/__init__.py +0 -0
- quoll/ci/config.py +213 -0
- quoll/ci/custom.py +32 -0
- quoll/ci/detect.py +139 -0
- quoll/ci/steps.py +85 -0
- quoll/ci/tools.py +104 -0
- quoll/commands/__init__.py +0 -0
- quoll/commands/_common.py +145 -0
- quoll/commands/_workspace.py +83 -0
- quoll/commands/add.py +104 -0
- quoll/commands/add_secret.py +75 -0
- quoll/commands/cd.py +214 -0
- quoll/commands/ci.py +521 -0
- quoll/commands/docker.py +244 -0
- quoll/commands/k8s.py +211 -0
- quoll/commands/new.py +337 -0
- quoll/commands/remove.py +79 -0
- quoll/config/__init__.py +0 -0
- quoll/config/quoll_toml.py +196 -0
- quoll/config/settings.py +22 -0
- quoll/config/store.py +39 -0
- quoll/console.py +3 -0
- quoll/docker/__init__.py +1 -0
- quoll/docker/config.py +64 -0
- quoll/docker/detect.py +41 -0
- quoll/docker/render.py +162 -0
- quoll/files.py +64 -0
- quoll/k8s/__init__.py +0 -0
- quoll/k8s/chart/templates/_helpers.tpl +38 -0
- quoll/k8s/chart/templates/deployment.yaml +93 -0
- quoll/k8s/chart/templates/hpa.yaml +24 -0
- quoll/k8s/chart/templates/ingress.yaml +35 -0
- quoll/k8s/chart/templates/pdb.yaml +16 -0
- quoll/k8s/chart/templates/service.yaml +18 -0
- quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
- quoll/k8s/ci.py +39 -0
- quoll/k8s/config.py +97 -0
- quoll/k8s/deploy.py +22 -0
- quoll/k8s/detect.py +32 -0
- quoll/k8s/render.py +94 -0
- quoll/languages/__init__.py +121 -0
- quoll/languages/base.py +350 -0
- quoll/languages/cpp/Dockerfile.j2 +37 -0
- quoll/languages/cpp/__init__.py +17 -0
- quoll/languages/cpp/ci.py +192 -0
- quoll/languages/cpp/detect.py +84 -0
- quoll/languages/cpp/docker.py +47 -0
- quoll/languages/dotnet/Dockerfile.j2 +21 -0
- quoll/languages/dotnet/__init__.py +17 -0
- quoll/languages/dotnet/ci.py +88 -0
- quoll/languages/dotnet/detect.py +53 -0
- quoll/languages/dotnet/docker.py +47 -0
- quoll/languages/go/Dockerfile.j2 +25 -0
- quoll/languages/go/__init__.py +17 -0
- quoll/languages/go/ci.py +126 -0
- quoll/languages/go/detect.py +30 -0
- quoll/languages/go/docker.py +40 -0
- quoll/languages/java/Dockerfile.j2 +28 -0
- quoll/languages/java/__init__.py +20 -0
- quoll/languages/java/ci.py +163 -0
- quoll/languages/java/detect.py +77 -0
- quoll/languages/java/docker.py +45 -0
- quoll/languages/java/frameworks/__init__.py +1 -0
- quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
- quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
- quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
- quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
- quoll/languages/java/frameworks/spring_boot/services.py +24 -0
- quoll/languages/node/Dockerfile.j2 +98 -0
- quoll/languages/node/__init__.py +29 -0
- quoll/languages/node/ci.py +200 -0
- quoll/languages/node/detect.py +164 -0
- quoll/languages/node/docker.py +141 -0
- quoll/languages/node/frameworks/__init__.py +1 -0
- quoll/languages/node/frameworks/angular/__init__.py +18 -0
- quoll/languages/node/frameworks/angular/ci.py +34 -0
- quoll/languages/node/frameworks/angular/detect.py +20 -0
- quoll/languages/node/frameworks/angular/docker.py +31 -0
- quoll/languages/node/frameworks/angular/starter.py +74 -0
- quoll/languages/node/frameworks/astro/__init__.py +10 -0
- quoll/languages/node/frameworks/astro/ci.py +17 -0
- quoll/languages/node/frameworks/astro/detect.py +14 -0
- quoll/languages/node/frameworks/astro/docker.py +17 -0
- quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
- quoll/languages/node/frameworks/nestjs/detect.py +9 -0
- quoll/languages/node/frameworks/nestjs/docker.py +10 -0
- quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
- quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
- quoll/languages/node/frameworks/nextjs/ci.py +29 -0
- quoll/languages/node/frameworks/nextjs/detect.py +13 -0
- quoll/languages/node/frameworks/nextjs/docker.py +41 -0
- quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
- quoll/languages/node/frameworks/nuxt/ci.py +17 -0
- quoll/languages/node/frameworks/nuxt/detect.py +17 -0
- quoll/languages/node/frameworks/nuxt/docker.py +24 -0
- quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
- quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
- quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
- quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
- quoll/languages/node/starter.py +42 -0
- quoll/languages/node/templates/express/README.md +11 -0
- quoll/languages/node/templates/express/env.example +2 -0
- quoll/languages/node/templates/express/eslint.config.js +15 -0
- quoll/languages/node/templates/express/gitignore +4 -0
- quoll/languages/node/templates/express/nvmrc +1 -0
- quoll/languages/node/templates/express/package.json +16 -0
- quoll/languages/node/templates/express/prettierignore +6 -0
- quoll/languages/node/templates/express/src/app.ts +27 -0
- quoll/languages/node/templates/express/src/server.ts +11 -0
- quoll/languages/node/templates/express/test/app.test.ts +29 -0
- quoll/languages/node/templates/express/tsconfig.build.json +8 -0
- quoll/languages/node/templates/express/tsconfig.json +14 -0
- quoll/languages/node/templates/express/vitest.config.ts +12 -0
- quoll/languages/php/Dockerfile.j2 +40 -0
- quoll/languages/php/__init__.py +19 -0
- quoll/languages/php/ci.py +147 -0
- quoll/languages/php/detect.py +75 -0
- quoll/languages/php/docker.py +59 -0
- quoll/languages/php/frameworks/__init__.py +1 -0
- quoll/languages/php/frameworks/laravel/__init__.py +17 -0
- quoll/languages/php/frameworks/laravel/ci.py +24 -0
- quoll/languages/php/frameworks/laravel/detect.py +13 -0
- quoll/languages/php/frameworks/laravel/docker.py +29 -0
- quoll/languages/php/frameworks/laravel/services.py +25 -0
- quoll/languages/php/frameworks/symfony/__init__.py +15 -0
- quoll/languages/php/frameworks/symfony/ci.py +22 -0
- quoll/languages/php/frameworks/symfony/detect.py +17 -0
- quoll/languages/php/frameworks/symfony/docker.py +25 -0
- quoll/languages/python/Dockerfile.j2 +61 -0
- quoll/languages/python/__init__.py +19 -0
- quoll/languages/python/ci.py +157 -0
- quoll/languages/python/detect.py +175 -0
- quoll/languages/python/docker.py +80 -0
- quoll/languages/python/frameworks/__init__.py +1 -0
- quoll/languages/python/frameworks/django/__init__.py +16 -0
- quoll/languages/python/frameworks/django/ci.py +43 -0
- quoll/languages/python/frameworks/django/detect.py +17 -0
- quoll/languages/python/frameworks/django/docker.py +20 -0
- quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
- quoll/languages/python/frameworks/fastapi/detect.py +9 -0
- quoll/languages/python/frameworks/fastapi/docker.py +21 -0
- quoll/languages/python/frameworks/flask/__init__.py +7 -0
- quoll/languages/python/frameworks/flask/detect.py +9 -0
- quoll/languages/python/frameworks/flask/docker.py +22 -0
- quoll/languages/ruby/Dockerfile.j2 +45 -0
- quoll/languages/ruby/__init__.py +19 -0
- quoll/languages/ruby/ci.py +110 -0
- quoll/languages/ruby/detect.py +72 -0
- quoll/languages/ruby/docker.py +63 -0
- quoll/languages/ruby/frameworks/__init__.py +1 -0
- quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
- quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
- quoll/languages/ruby/frameworks/rails/ci.py +26 -0
- quoll/languages/ruby/frameworks/rails/detect.py +15 -0
- quoll/languages/ruby/frameworks/rails/docker.py +62 -0
- quoll/languages/ruby/frameworks/rails/services.py +10 -0
- quoll/languages/rust/Dockerfile.j2 +23 -0
- quoll/languages/rust/__init__.py +17 -0
- quoll/languages/rust/ci.py +125 -0
- quoll/languages/rust/detect.py +46 -0
- quoll/languages/rust/docker.py +38 -0
- quoll/main.py +50 -0
- quoll/platforms/__init__.py +62 -0
- quoll/platforms/base.py +103 -0
- quoll/platforms/github/__init__.py +4 -0
- quoll/platforms/github/actions.py +78 -0
- quoll/platforms/github/api.py +124 -0
- quoll/platforms/github/cd.py +265 -0
- quoll/platforms/github/ci.py +497 -0
- quoll/platforms/github/dependabot.yml.j2 +49 -0
- quoll/platforms/github/platform.py +52 -0
- quoll/platforms/github/workflow.py +74 -0
- quoll/platforms/gitlab/__init__.py +4 -0
- quoll/platforms/gitlab/api.py +158 -0
- quoll/platforms/gitlab/cd.py +226 -0
- quoll/platforms/gitlab/ci.py +400 -0
- quoll/platforms/gitlab/pipeline.py +136 -0
- quoll/platforms/gitlab/platform.py +54 -0
- quoll/project.py +60 -0
- quoll/services.py +117 -0
- quoll/starters.py +117 -0
- quoll/workspace.py +232 -0
- quoll/yaml_dump.py +137 -0
- quoll_cli-0.2.0.dist-info/METADATA +641 -0
- quoll_cli-0.2.0.dist-info/RECORD +191 -0
- quoll_cli-0.2.0.dist-info/WHEEL +4 -0
- quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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")
|
quoll/commands/remove.py
ADDED
|
@@ -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
|
+
|
quoll/config/__init__.py
ADDED
|
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)}: "
|
quoll/config/settings.py
ADDED
|
@@ -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)
|