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/ci.py ADDED
@@ -0,0 +1,521 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Dict, List, Optional, Sequence, Tuple, Union
5
+
6
+ import questionary
7
+ import typer
8
+ from pydantic import ValidationError
9
+ from rich.table import Table
10
+
11
+ from quoll import platforms
12
+ from quoll.ci.config import CONFIG_FILE, EXTENSIBLE_JOBS, CIConfig, ConfigError, CustomStep, load_config, save_config
13
+ from quoll.ci.detect import Detection, configured_platform, default_branch, default_config, detect
14
+ from quoll.commands._common import (
15
+ DirOption,
16
+ ask,
17
+ check_files,
18
+ check_platform,
19
+ confirm_overwrite,
20
+ ensure_token,
21
+ platform_option,
22
+ resolve_dir,
23
+ supported_hint,
24
+ update_files,
25
+ )
26
+ from quoll.config import quoll_toml
27
+ from quoll.console import console
28
+ from quoll.files import eject as eject_text
29
+ from quoll.files import write
30
+ from quoll.platforms import PlatformError
31
+ from quoll.platforms.base import PlatformAPI
32
+ from quoll.workspace import Member, Repo, RepoCIConfig, discover, find_root, load_repo, member_name, save_workspace
33
+
34
+ app = typer.Typer(help="Generate and maintain the CI pipeline (GitHub Actions or GitLab CI).", no_args_is_help=True)
35
+
36
+
37
+ @app.command()
38
+ def init(
39
+ project_dir: Path = DirOption,
40
+ yes: bool = typer.Option(False, "--yes", "-y", help="Accept detected defaults without asking."),
41
+ force: bool = typer.Option(False, "--force", help="Overwrite a pipeline that was not generated by quoll."),
42
+ platform: Optional[str] = platform_option(),
43
+ local: bool = typer.Option(
44
+ False, "--local", "--no-github", help="Only write files: skip branch protection and secrets."
45
+ ),
46
+ ) -> None:
47
+ """Detect the project (or the projects of a monorepo), write quoll.toml and generate the CI pipeline."""
48
+ project_dir = resolve_dir(project_dir)
49
+ check_platform(platform)
50
+ console.print(f"\n[bold]quoll ci[/bold] — {project_dir.name}\n")
51
+
52
+ if _configured(project_dir) and not force:
53
+ if yes or not ask(questionary.confirm(
54
+ f"{CONFIG_FILE} already configures CI. Detect again and replace it?", default=False
55
+ )):
56
+ console.print("Nothing changed. Use [bold]quoll ci update[/bold] to regenerate from quoll.toml.")
57
+ raise typer.Exit(0)
58
+
59
+ members = discover(project_dir)
60
+ if "." in members and len(members) > 1:
61
+ # The root's quoll.toml can't be both a project's and the workspace's.
62
+ others = ", ".join(m for m in members if m != ".")
63
+ console.print(f"[yellow]![/yellow] Also found {others}: a workspace needs each project in a directory of its "
64
+ "own (move the root's into one). Setting up the root project alone.\n")
65
+ elif members and members != ["."]: # the root alone: a single project
66
+ _init_workspace(project_dir, members, yes=yes, force=force, platform=platform, local=local)
67
+ return
68
+
69
+ det = detect(project_dir)
70
+ if det.stack is None:
71
+ console.print(f"[red]Error:[/red] no supported project found.\n{supported_hint()}")
72
+ raise typer.Exit(1)
73
+ if platform:
74
+ det.platform = platform
75
+ _print_detection(det)
76
+
77
+ cfg = default_config(det)
78
+ if not yes:
79
+ cfg = _customize(cfg, det)
80
+
81
+ files = _plan(project_dir, Repo.single(cfg))
82
+ if not confirm_overwrite(files, yes=yes, force=force):
83
+ raise typer.Exit(1)
84
+
85
+ save_config(project_dir, cfg)
86
+ write(project_dir, files)
87
+ console.print()
88
+ console.print(f"[green]✓[/green] {CONFIG_FILE}")
89
+ for f in files:
90
+ console.print(f"[green]✓[/green] {f.path}")
91
+
92
+ repo = Repo.single(cfg)
93
+ _finish(project_dir, repo, [cfg], det, files, yes=yes, local=local)
94
+
95
+
96
+ def _init_workspace(
97
+ root: Path, found: List[str], *, yes: bool, force: bool, platform: Optional[str], local: bool
98
+ ) -> None:
99
+ """A monorepo: one quoll.toml per project, the workspace's at the root, one pipeline for all."""
100
+ remote = platforms.remote(root)
101
+ name = platform or configured_platform(root) or platforms.detect(root, remote)
102
+ table = Table(title="Projects found", box=None, padding=(0, 2), title_justify="left")
103
+ for column in ("Directory", "Stack", "Framework", "Tests"):
104
+ table.add_column(column, style="bold" if column == "Directory" else "")
105
+ detections = {d: detect(root / d) for d in found}
106
+ for directory, det in detections.items():
107
+ table.add_row(directory, det.stack or "-", det.framework or "-", det.test_runner or "-")
108
+ console.print(table)
109
+ console.print()
110
+ members = found if yes else ask(questionary.checkbox(
111
+ "Projects of the workspace", choices=[questionary.Choice(d, d, checked=True) for d in found]
112
+ ))
113
+ if not members:
114
+ raise typer.Exit(1)
115
+
116
+ configs: List[CIConfig] = []
117
+ for directory in members:
118
+ det = detections[directory]
119
+ det.platform = name
120
+ console.print(f"\n[bold]{directory}[/bold]")
121
+ _print_detection(det)
122
+ cfg = default_config(det)
123
+ if not yes:
124
+ cfg = _customize(cfg, det, workspace=True)
125
+ configs.append(cfg.in_directory(directory))
126
+
127
+ shared = RepoCIConfig(platform=name, branches=[default_branch(root)])
128
+ repo = Repo(
129
+ platform=name,
130
+ branches=list(shared.branches),
131
+ dependency_updates=shared.dependency_updates,
132
+ schedule=shared.schedule,
133
+ secrets=shared.security.secrets,
134
+ sast=shared.security.sast,
135
+ members=[Member(member_name(c.directory), c) for c in configs],
136
+ )
137
+ files = _plan(root, repo)
138
+ if not confirm_overwrite(files, yes=yes, force=force):
139
+ raise typer.Exit(1)
140
+ for cfg in configs:
141
+ save_config(root / cfg.directory, cfg)
142
+ save_workspace(root, members, shared)
143
+ write(root, files)
144
+ console.print()
145
+ console.print(f"[green]✓[/green] {CONFIG_FILE} (workspace: {', '.join(members)})")
146
+ for cfg in configs:
147
+ console.print(f"[green]✓[/green] {cfg.path(CONFIG_FILE)}")
148
+ for f in files:
149
+ console.print(f"[green]✓[/green] {f.path}")
150
+ det = detections[members[0]]
151
+ det.env_vars = {k: v for d in members for k, v in detections[d].env_vars.items()}
152
+ _finish(root, repo, configs, det, [], yes=yes, local=local, extra=[cfg.path(CONFIG_FILE) for cfg in configs])
153
+
154
+
155
+ def _finish(
156
+ root: Path,
157
+ repo: Repo,
158
+ configs: List[CIConfig],
159
+ det: Detection,
160
+ files: list,
161
+ *,
162
+ yes: bool,
163
+ local: bool,
164
+ extra: Sequence[str] = (),
165
+ ) -> None:
166
+ platform = platforms.get(repo.platform)
167
+ if not yes and not local:
168
+ _remote_setup(repo, configs, det)
169
+ elif repo.schedule.enabled and platform.schedules_via_api:
170
+ console.print("\n[yellow]![/yellow] The weekly security scans need a pipeline schedule: run "
171
+ "[bold]quoll ci schedule[/bold] once pushed.")
172
+
173
+ planned_files = files or _plan(root, repo)
174
+ paths = sorted({f.path.parts[0] + ("/" if len(f.path.parts) > 1 else "") for f in planned_files})
175
+ console.print("\n[bold green]Done![/bold green] Commit and push to run the pipeline:")
176
+ console.print(f" git add {' '.join([CONFIG_FILE, *extra, *paths])}")
177
+ console.print(' git commit -m "ci: add quoll pipeline"')
178
+ console.print(" git push")
179
+ console.print(f"\n[dim]{platform.merge_gate()}[/dim]")
180
+
181
+
182
+ @app.command()
183
+ def update(
184
+ project_dir: Path = DirOption,
185
+ yes: bool = typer.Option(False, "--yes", "-y", help="Write changes without asking."),
186
+ force: bool = typer.Option(False, "--force", help="Overwrite a pipeline that was not generated by quoll."),
187
+ ) -> None:
188
+ """Regenerate the pipeline from quoll.toml (the workspace's, from any of its directories)."""
189
+ root, repo = _repo(resolve_dir(project_dir))
190
+ update_files(root, _plan(root, repo), yes=yes, force=force, what="CI is")
191
+
192
+
193
+ @app.command()
194
+ def check(project_dir: Path = DirOption) -> None:
195
+ """Fail if the pipeline differs from what quoll.toml describes (use it in CI or pre-commit)."""
196
+ root, repo = _repo(resolve_dir(project_dir))
197
+ check_files([f for f in _plan(root, repo) if not f.create_only], "ci")
198
+
199
+
200
+ @app.command()
201
+ def eject(
202
+ project_dir: Path = DirOption,
203
+ yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask for confirmation."),
204
+ ) -> None:
205
+ """Stop managing the CI: its files become yours, as they are, and [ci] leaves quoll.toml."""
206
+ root, repo = _repo(resolve_dir(project_dir))
207
+ files = [f for f in _plan(root, repo) if not f.create_only and f.existing and f.managed]
208
+ if not yes and not ask(questionary.confirm(
209
+ f"quoll will no longer update {', '.join(str(f.path) for f in files)}. Eject?", default=False
210
+ )):
211
+ raise typer.Exit(1)
212
+ for f in files:
213
+ (root / f.path).write_text(eject_text(f.existing, "ci"))
214
+ console.print(f"[green]✓[/green] {f.path} is yours")
215
+ for directory in sorted({".", *(m.directory for m in repo.members)}):
216
+ quoll_toml.remove_section(root / directory, "ci")
217
+ console.print(f"[green]✓[/green] [ci] removed from {Path(directory, CONFIG_FILE).as_posix()}")
218
+
219
+
220
+ @app.command()
221
+ def schedule(project_dir: Path = DirOption) -> None:
222
+ """Create or update the schedule of the security scans ([ci.schedule]) where it lives outside the pipeline."""
223
+ root, repo = _repo(resolve_dir(project_dir))
224
+ platform = platforms.get(repo.platform)
225
+ if not repo.schedule.enabled:
226
+ console.print("Scheduled scans are off ([ci.schedule] enabled = false).")
227
+ return
228
+ if not platform.schedules_via_api:
229
+ console.print(f"[green]✓[/green] On {platform.label} the schedule ({repo.schedule.cron}) is in the pipeline "
230
+ "itself: `quoll ci update` keeps it current.")
231
+ return
232
+ remote = platforms.remote(root)
233
+ if remote is None or platforms.hosting(remote) != platform.name:
234
+ console.print(f"[red]Error:[/red] no {platform.label} remote (origin) found.")
235
+ raise typer.Exit(1)
236
+ if not ensure_token(platform.name, remote.host):
237
+ raise typer.Exit(1)
238
+ _schedule(platform.api(remote.host), remote.path, repo.branches[0], repo.schedule.cron)
239
+
240
+
241
+ @app.command(name="add-step")
242
+ def add_step(
243
+ project_dir: Path = DirOption,
244
+ job: Optional[str] = typer.Option(None, help=f"Job to extend: {', '.join(EXTENSIBLE_JOBS)}."),
245
+ when: Optional[str] = typer.Option(None, help="before (once dependencies are installed) or after (at the end)."),
246
+ name: Optional[str] = typer.Option(None, help="Step name, shown in the CI."),
247
+ run: Optional[str] = typer.Option(None, help="Shell command, run in the project's directory."),
248
+ uses: Optional[str] = typer.Option(None, help="A GitHub Action instead (GitHub only), e.g. owner/action@v1."),
249
+ with_: List[str] = typer.Option([], "--with", help="Action input KEY=VALUE (repeatable)."),
250
+ secret: List[str] = typer.Option([], "--secret", help="CI secret / variable the step reads (repeatable)."),
251
+ ) -> None:
252
+ """Add a step to a generated job ([[ci.steps]] in quoll.toml), then regenerate the pipeline."""
253
+ project_dir = resolve_dir(project_dir)
254
+ if "workspace" in _read(project_dir):
255
+ members = load_repo(project_dir).members
256
+ console.print(f"Steps belong to a project of the workspace: run with -d <project> "
257
+ f"({', '.join(m.cfg.directory for m in members)}).")
258
+ raise typer.Exit(1)
259
+ try:
260
+ cfg = load_config(project_dir)
261
+ except ConfigError as e:
262
+ console.print(f"[red]Error:[/red] {e}")
263
+ raise typer.Exit(1)
264
+
265
+ job = job or ask(questionary.select("Job", choices=list(EXTENSIBLE_JOBS)))
266
+ when = when or ask(questionary.select("When", choices=[
267
+ questionary.Choice("before its own commands (dependencies installed)", "before"),
268
+ questionary.Choice("at the end", "after"),
269
+ ]))
270
+ name = name or ask(questionary.text("Step name", validate=lambda v: bool(v.strip()) or "Required"))
271
+ if not run and not uses:
272
+ run = ask(questionary.text("Command", validate=lambda v: bool(v.strip()) or "Required"))
273
+ if uses and cfg.platform != "github":
274
+ console.print("[red]Error:[/red] actions (--uses) are GitHub's: give a command with --run.")
275
+ raise typer.Exit(1)
276
+ inputs = dict(item.split("=", 1) for item in with_ if "=" in item)
277
+ try:
278
+ added = CustomStep.model_validate({
279
+ "job": job, "when": when, "name": name, "run": run or "", "uses": uses or "", "with": inputs,
280
+ "secrets": list(secret),
281
+ })
282
+ except ValidationError as e:
283
+ console.print(f"[red]Error:[/red] {e.errors()[0]['msg']}")
284
+ raise typer.Exit(1)
285
+ cfg.steps.append(added)
286
+ save_config(project_dir, cfg)
287
+ console.print(f"[green]✓[/green] {CONFIG_FILE}: step '{name}' added to the {job} job")
288
+ root, repo = _repo(project_dir)
289
+ files = [f for f in _plan(root, repo) if f.changed]
290
+ if any(not f.managed for f in files):
291
+ console.print("[yellow]![/yellow] The pipeline was written by hand: run [bold]quoll ci update --force[/bold] "
292
+ "to regenerate it.")
293
+ raise typer.Exit(1)
294
+ write(root, files)
295
+ for f in files:
296
+ console.print(f"[green]✓[/green] {f.path} updated")
297
+
298
+
299
+ def _read(project_dir: Path) -> dict:
300
+ try:
301
+ return quoll_toml.read(project_dir)
302
+ except ConfigError:
303
+ return {}
304
+
305
+
306
+ @app.command(name="detect")
307
+ def detect_cmd(project_dir: Path = DirOption) -> None:
308
+ """Show what quoll detects in the project (or each project of a monorepo), without writing anything."""
309
+ project_dir = resolve_dir(project_dir)
310
+ members = discover(project_dir)
311
+ if members and members != ["."]:
312
+ for directory in members:
313
+ console.print(f"[bold]{directory}[/bold]")
314
+ _print_detection(detect(project_dir / directory))
315
+ return
316
+ det = detect(project_dir)
317
+ if det.stack is None:
318
+ console.print(f"[yellow]No supported project found.[/yellow]\n{supported_hint()}")
319
+ raise typer.Exit(1)
320
+ _print_detection(det)
321
+
322
+
323
+ # --------------------------------------------------------------------------- interactive
324
+
325
+
326
+ def _customize(cfg: CIConfig, det: Detection, workspace: bool = False) -> CIConfig:
327
+ data = cfg.model_dump(by_alias=True)
328
+ # A workspace scans secrets and code once, for the whole repository (its root quoll.toml).
329
+ repository_scans = [] if workspace else [
330
+ questionary.Choice("Secret scanning (gitleaks)", "secrets", checked=True),
331
+ questionary.Choice("Static analysis (Semgrep)", "sast", checked=True),
332
+ ]
333
+ jobs = [
334
+ questionary.Choice("Lint", "lint", checked=cfg.lint.enabled),
335
+ questionary.Choice(f"Tests ({cfg.test.runner or 'none detected'})", "test", checked=cfg.test.enabled),
336
+ questionary.Choice("Dependency audit", "audit", checked=cfg.security.audit),
337
+ *repository_scans,
338
+ questionary.Choice(f"Build ({cfg.build.command or 'none detected'})", "build", checked=cfg.build.enabled),
339
+ questionary.Choice("Docker image (hadolint, build, Trivy scan)", "docker", checked=det.has_dockerfile),
340
+ *(
341
+ [questionary.Choice("CodeQL (needs GitHub Advanced Security on private repos)", "codeql", checked=False)]
342
+ if cfg.platform == "github" and cfg.language.ci.codeql_language
343
+ else []
344
+ ),
345
+ questionary.Choice("SonarCloud", "sonar", checked=False),
346
+ ]
347
+ selected = ask(questionary.checkbox("Jobs", choices=jobs))
348
+
349
+ data["lint"]["enabled"] = "lint" in selected
350
+ if data["lint"]["enabled"]:
351
+ known = cfg.lint_tools
352
+ options = [t for t in cfg.lint.tools if t not in known] + known
353
+ data["lint"]["tools"] = ask(questionary.checkbox(
354
+ "Lint tools",
355
+ choices=[questionary.Choice(t, t, checked=t in cfg.lint.tools) for t in options],
356
+ ))
357
+ data["lint"]["enabled"] = bool(data["lint"]["tools"])
358
+
359
+ data["test"]["enabled"] = "test" in selected and bool(cfg.test.runner)
360
+ if "test" in selected and not cfg.test.runner:
361
+ console.print(" [yellow]No test runner detected — set test.runner in quoll.toml to enable tests.[/yellow]")
362
+ if data["test"]["enabled"]:
363
+ if cfg.test.coverage:
364
+ threshold = ask(questionary.text(
365
+ "Minimum coverage (%)",
366
+ default=str(cfg.test.coverage_threshold),
367
+ validate=lambda v: v.isdigit() and 0 <= int(v) <= 100 or "Enter a number between 0 and 100",
368
+ ))
369
+ data["test"]["coverage_threshold"] = int(threshold)
370
+ data["test"]["codecov"] = ask(questionary.confirm("Upload coverage to Codecov?", default=False))
371
+ if len(det.matrix_versions) > 1:
372
+ versions = ", ".join(det.matrix_versions)
373
+ if ask(questionary.confirm(f"Test against several versions ({versions})?", default=False)):
374
+ data["versions"] = list(det.matrix_versions)
375
+ if det.services:
376
+ data["services"] = ask(questionary.checkbox(
377
+ "Start these services for the tests",
378
+ choices=[questionary.Choice(s, s, checked=True) for s in det.services],
379
+ ))
380
+ if det.env_vars:
381
+ platform = platforms.get(cfg.platform)
382
+ data["test"]["env_secrets"] = ask(questionary.checkbox(
383
+ f"Expose these .env.example variables to tests (as {platform.label} {platform.secrets_label})",
384
+ choices=[questionary.Choice(k, k, checked=True) for k in det.env_vars],
385
+ ))
386
+
387
+ data["security"]["audit"] = "audit" in selected
388
+ if not workspace:
389
+ data["security"]["secrets"] = "secrets" in selected
390
+ data["security"]["sast"] = "sast" in selected
391
+ data["security"]["codeql"] = "codeql" in selected
392
+ data["security"]["sonar"] = "sonar" in selected
393
+ if data["security"]["sonar"]:
394
+ owner = det.remote.path.split("/")[0] if det.remote else ""
395
+ data["security"]["sonar_organization"] = ask(questionary.text("SonarCloud organization", default=owner))
396
+ if det.remote:
397
+ data["security"]["sonar_project_key"] = det.remote.path.replace("/", "_")
398
+
399
+ data["build"]["enabled"] = "build" in selected and bool(cfg.build.command)
400
+ data["build"]["docker"] = "docker" in selected
401
+ return CIConfig.model_validate(data)
402
+
403
+
404
+ def _remote_setup(repo: Repo, configs: List[CIConfig], det: Detection) -> None:
405
+ platform = platforms.get(repo.platform)
406
+ remote = det.remote
407
+ if remote is None or platforms.hosting(remote) != platform.name:
408
+ console.print(f"\n[dim]No {platform.label} remote (origin) found — skipping branch protection and "
409
+ f"{platform.secrets_label}.[/dim]")
410
+ return
411
+ branch = repo.branches[0]
412
+
413
+ protect = ask(questionary.confirm(
414
+ f"Require the pipeline to pass before merging into {remote.path}@{branch}?", default=True
415
+ ))
416
+
417
+ secrets: Dict[str, str] = {}
418
+ wanted: List[str] = list(dict.fromkeys(s for cfg in configs for s in cfg.test.env_secrets))
419
+ if any(cfg.security.sonar for cfg in configs):
420
+ wanted.append("SONAR_TOKEN")
421
+ if any(cfg.test.codecov for cfg in configs):
422
+ wanted.append("CODECOV_TOKEN")
423
+ if wanted and ask(questionary.confirm(
424
+ f"Set the {platform.label} {platform.secrets_label} now ({', '.join(wanted)})?", default=True
425
+ )):
426
+ for key in wanted:
427
+ default = det.env_vars.get(key, "")
428
+ value = ask(questionary.password(f"{key}" + (" (Enter = .env.example value)" if default else "")))
429
+ if value or default:
430
+ secrets[key] = value or default
431
+
432
+ schedule = repo.schedule.enabled and platform.schedules_via_api and ask(questionary.confirm(
433
+ f"Schedule the security scans ({repo.schedule.cron}, UTC) on {branch}?", default=True
434
+ ))
435
+
436
+ if not protect and not secrets and not schedule:
437
+ return
438
+ if not ensure_token(platform.name, remote.host):
439
+ console.print(f"[yellow]No {platform.label} token — skipped.[/yellow]")
440
+ return
441
+
442
+ api = platform.api(remote.host)
443
+ if protect:
444
+ try:
445
+ for warning in api.protect_branch(remote.path, branch, required_checks=platform.required_checks):
446
+ console.print(f"[yellow]Warning:[/yellow] {warning}")
447
+ console.print(f"[green]✓[/green] {branch} is protected: merges require a green pipeline")
448
+ except PlatformError as e:
449
+ console.print(f"[yellow]Warning:[/yellow] branch protection failed: {e}")
450
+ if schedule:
451
+ _schedule(api, remote.path, branch, repo.schedule.cron)
452
+ if secrets:
453
+ try:
454
+ api.set_secrets(remote.path, secrets)
455
+ console.print(f"[green]✓[/green] {len(secrets)} {platform.secrets_label} set: {', '.join(secrets)}")
456
+ except Exception as e: # httpx errors, missing PyNaCl...
457
+ console.print(f"[yellow]Warning:[/yellow] setting {platform.secrets_label} failed: {e}")
458
+
459
+
460
+ def _schedule(api: PlatformAPI, repo: str, branch: str, cron: str) -> None:
461
+ try:
462
+ api.ensure_schedule(repo, branch, cron)
463
+ console.print(f"[green]✓[/green] Security scans scheduled on {branch} ({cron}, UTC)")
464
+ except PlatformError as e:
465
+ console.print(f"[yellow]Warning:[/yellow] scheduling the security scans failed: {e}")
466
+
467
+
468
+ # --------------------------------------------------------------------------- output
469
+
470
+
471
+ def _print_detection(det: Detection) -> None:
472
+ table = Table(show_header=False, box=None, padding=(0, 2))
473
+ table.add_column(style="dim")
474
+ table.add_column(style="bold")
475
+ version = f"{det.version} (from {det.version_file})" if det.version_file else det.version
476
+ table.add_row("Stack", det.stack or "-")
477
+ table.add_row("Framework", det.framework or "-")
478
+ table.add_row("Package manager", det.package_manager + (f" ({det.lockfile})" if det.lockfile else ""))
479
+ table.add_row("Version", version)
480
+ table.add_row("Lint", ", ".join(det.lint_tools) or "-")
481
+ table.add_row("Tests", det.test_runner + ("" if det.coverage or not det.test_runner else " (no coverage)") or "-")
482
+ table.add_row("Build", det.build_command.replace("\n", " && ") or "-")
483
+ table.add_row("Services", ", ".join(det.services) or "-")
484
+ table.add_row("Dockerfile", "yes" if det.has_dockerfile else "no")
485
+ table.add_row("Default branch", det.default_branch)
486
+ remote = f" ({det.remote.host}/{det.remote.path})" if det.remote else ""
487
+ table.add_row("Platform", platforms.get(det.platform).label + remote)
488
+ console.print(table)
489
+ for warning in det.warnings:
490
+ console.print(f"[yellow]![/yellow] {warning}")
491
+ console.print()
492
+
493
+
494
+ # --------------------------------------------------------------------------- helpers
495
+
496
+
497
+ def _plan(root: Path, target: Union[CIConfig, Repo]) -> list:
498
+ platform = target.platform
499
+ try:
500
+ return platforms.get(platform).ci_files(root, target)
501
+ except ValueError as e:
502
+ console.print(f"[red]Error:[/red] {e}")
503
+ raise typer.Exit(1)
504
+
505
+
506
+ def _repo(project_dir: Path) -> Tuple[Path, Repo]:
507
+ """The repository `project_dir` belongs to (its workspace root, if any) and its pipeline settings."""
508
+ root = find_root(project_dir)
509
+ try:
510
+ return root, load_repo(root)
511
+ except ConfigError as e:
512
+ console.print(f"[red]Error:[/red] {e}")
513
+ raise typer.Exit(1)
514
+
515
+
516
+ def _configured(project_dir: Path) -> bool:
517
+ try:
518
+ data = quoll_toml.read(project_dir)
519
+ except ConfigError:
520
+ return True
521
+ return "ci" in data or "workspace" in data