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
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence
4
+
5
+ from quoll.ci.steps import ROOT, Cache, Item, Step, step
6
+ from quoll.languages.base import SCRIPT_PREFIX, CISteps, TestReports
7
+ from quoll.platforms.github.actions import ACTIONS
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+ from quoll.languages.base import Detection
12
+
13
+ _EXEC = {"npm": "npx", "pnpm": "pnpm exec", "yarn": "yarn", "yarn-classic": "yarn", "bun": "bunx"}
14
+
15
+ # Files (and directories) quoll writes in a Node project, kept out of its formatter (as in .prettierignore).
16
+ # The Helm chart's templates are YAML with Go templates in it: no YAML formatter can read them.
17
+ QUOLL_FILES = ("compose.yaml", ".github/", ".gitlab-ci.yml", ".gitlab/", "chart/")
18
+ _NOT_QUOLL_FILES = " ".join(f"'!{p}**'" if p.endswith("/") else f"'!{p}'" for p in QUOLL_FILES)
19
+
20
+ # tool: (step name, command)
21
+ _LINT = {
22
+ "eslint": ("ESLint", "eslint ."),
23
+ # quoll's files (compose.yaml, the pipelines) follow their own format, not the project's Prettier config.
24
+ "prettier": ("Prettier", f"prettier --check . {_NOT_QUOLL_FILES}"),
25
+ "biome": ("Biome", "biome ci ."),
26
+ "tsc": ("Type check", "tsc --noEmit"),
27
+ }
28
+
29
+ _COVERAGE_RUNNERS = ("vitest", "jest", "bun")
30
+ _BUILD_OUTPUT = {"nextjs": [], "nuxt": [".output/"], "sveltekit": ["build/"]}
31
+ _JEST_JUNIT = "/tmp/quoll-jest-junit"
32
+ _JEST_JUNIT_INSTALL = f"npm install --no-save --no-package-lock --prefix {_JEST_JUNIT} jest-junit@16"
33
+
34
+ LOCKFILES = {
35
+ "npm": "package-lock.json",
36
+ "pnpm": "pnpm-lock.yaml",
37
+ "yarn": "yarn.lock",
38
+ "yarn-classic": "yarn.lock",
39
+ "bun": "bun.lock",
40
+ }
41
+
42
+ # Package manager caches, kept in the project by container jobs: (directory, variable pointing the tool at it).
43
+ _CACHE = {
44
+ "npm": (".npm", "npm_config_cache"),
45
+ "pnpm": (".pnpm-store", "npm_config_store_dir"),
46
+ "yarn": (".yarn/cache", "YARN_CACHE_FOLDER"),
47
+ "yarn-classic": (".yarn-cache", "YARN_CACHE_FOLDER"),
48
+ }
49
+
50
+ _AUDIT = {
51
+ "npm": "npm audit --omit=dev --audit-level=high",
52
+ "pnpm": "pnpm audit --prod --audit-level high",
53
+ "yarn": "yarn npm audit --environment production --severity high",
54
+ "yarn-classic": "yarn audit --groups dependencies --level high",
55
+ "bun": "bun audit --prod --audit-level=high",
56
+ }
57
+
58
+
59
+ def run_script(package_manager: str, script: str) -> str:
60
+ if package_manager.startswith("yarn"):
61
+ return f"yarn run {script}"
62
+ return f"{package_manager} run {script}"
63
+
64
+
65
+ class NodeCI(CISteps):
66
+ # npm / pnpm / yarn / bun audit read the lockfile, installing nothing.
67
+ audit_uses_cache = False
68
+ version_key = "node-version"
69
+ codeql_language = "javascript-typescript"
70
+
71
+ def setup(self, cfg: CIConfig, version: Optional[str]) -> List[Item]:
72
+ pm = cfg.package_manager
73
+ pinned = cfg.package_manager_version
74
+ steps = []
75
+ if pm == "pnpm":
76
+ with_pnpm = {"version": pinned} if pinned else {}
77
+ if cfg.directory != ".":
78
+ with_pnpm["package_json_file"] = cfg.path("package.json")
79
+ steps.append(step("Set up pnpm", uses=ACTIONS["setup-pnpm"], with_=with_pnpm))
80
+ elif pm == "yarn":
81
+ # Yarn Berry is provided by corepack; it must be enabled before setup-node resolves the cache.
82
+ steps.append(step("Enable corepack", run="corepack enable"))
83
+ elif pm == "bun":
84
+ steps.append(
85
+ step("Set up Bun", uses=ACTIONS["setup-bun"], with_={"bun-version": pinned} if pinned else None)
86
+ )
87
+
88
+ with_: Dict[str, Any] = (
89
+ {"node-version": version} if version else {"node-version-file": cfg.path(cfg.version_file)}
90
+ )
91
+ if cfg.cache and pm != "bun":
92
+ with_["cache"] = "yarn" if pm.startswith("yarn") else pm
93
+ if cfg.directory != ".":
94
+ with_["cache-dependency-path"] = cfg.path(LOCKFILES[pm])
95
+ steps.append(step("Set up Node.js", uses=ACTIONS["setup-node"], with_=with_))
96
+ return steps
97
+
98
+ def image(self, cfg: CIConfig, version: str) -> str:
99
+ if cfg.package_manager == "bun":
100
+ return f"oven/bun:{cfg.package_manager_version or '1'}"
101
+ return f"node:{'lts' if version.startswith('lts') else version}"
102
+
103
+ def image_setup(self, cfg: CIConfig) -> List[Step]:
104
+ pm = cfg.package_manager
105
+ if pm not in ("pnpm", "yarn"):
106
+ return []
107
+ # Corepack provides the version pinned in package.json "packageManager"; Node 25+ no longer bundles it.
108
+ pinned = cfg.package_manager_version
109
+ run = "npm install --global corepack@latest\ncorepack enable"
110
+ if pinned:
111
+ run += f"\ncorepack install --global {pm}@{pinned}"
112
+ return [step(f"Set up {pm}", run=run, env={"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"})]
113
+
114
+ def cache(self, cfg: CIConfig) -> Optional[Cache]:
115
+ # Bun's cache holds unpacked sources that linters would scan: it installs fast enough without.
116
+ if not cfg.cache or cfg.package_manager not in _CACHE:
117
+ return None
118
+ directory, variable = _CACHE[cfg.package_manager]
119
+ env = {variable: f"{ROOT}/{directory}"}
120
+ if cfg.package_manager == "yarn":
121
+ env["YARN_ENABLE_GLOBAL_CACHE"] = "false"
122
+ return Cache("node", (directory,), key_files=(LOCKFILES[cfg.package_manager],), env=env)
123
+
124
+ def install(self, cfg: CIConfig, packages: Sequence[str] = ()) -> List[Item]:
125
+ return [step("Install dependencies", run=cfg.install)]
126
+
127
+ def exec(self, cfg: CIConfig, command: str) -> str:
128
+ return f"{_EXEC[cfg.package_manager]} {command}"
129
+
130
+ def lint(self, cfg: CIConfig, tools: List[str]) -> List[Item]:
131
+ steps = self.install(cfg)
132
+ for tool in tools:
133
+ if tool.startswith(SCRIPT_PREFIX):
134
+ script = tool[len(SCRIPT_PREFIX):]
135
+ steps.append(step(f"Run {script}", run=run_script(cfg.package_manager, script)))
136
+ else:
137
+ name, cmd = _LINT[tool]
138
+ steps.append(step(name, run=self.exec(cfg, cmd)))
139
+ return steps
140
+
141
+ def test(self, cfg: CIConfig) -> List[Item]:
142
+ t = cfg.test
143
+ coverage = t.coverage
144
+ setup: List[Item] = []
145
+ env: Dict[str, str] = {}
146
+ if t.runner == "jest":
147
+ cmd = self.exec(cfg, "jest --ci")
148
+ if coverage:
149
+ cmd += f""" --coverage --coverageThreshold='{{"global":{{"lines":{t.coverage_threshold}}}}}'"""
150
+ if t.reports:
151
+ cmd += " --coverageReporters=text --coverageReporters=lcov --coverageReporters=cobertura"
152
+ if t.reports:
153
+ # Jest has no JUnit output of its own: jest-junit, installed outside the project.
154
+ setup.append(step("JUnit reporter for Jest", run=_JEST_JUNIT_INSTALL))
155
+ cmd += f" --reporters=default --reporters={_JEST_JUNIT}/node_modules/jest-junit"
156
+ env = {"JEST_JUNIT_OUTPUT_DIR": ".", "JEST_JUNIT_OUTPUT_NAME": "junit.xml"}
157
+ elif t.runner == "vitest":
158
+ cmd = self.exec(cfg, "vitest run")
159
+ if t.reports:
160
+ cmd += " --reporter=default --reporter=junit --outputFile.junit=junit.xml"
161
+ if coverage:
162
+ cmd += (
163
+ " --coverage.enabled --coverage.reporter=text --coverage.reporter=lcov"
164
+ + (" --coverage.reporter=cobertura" if t.reports else "")
165
+ + f" --coverage.thresholds.lines={t.coverage_threshold}"
166
+ )
167
+ elif t.runner == "bun":
168
+ cmd = "bun test --coverage --coverage-reporter=lcov" if coverage else "bun test"
169
+ if t.reports:
170
+ cmd += " --reporter=junit --reporter-outfile=junit.xml"
171
+ else:
172
+ cmd = run_script(cfg.package_manager, t.runner[len(SCRIPT_PREFIX):])
173
+ coverage = False
174
+ name = "Tests" + (" with coverage" if coverage else "")
175
+ return [*self.install(cfg), *setup, step(name, run=cmd, env=env, secrets=t.env_secrets)]
176
+
177
+ def coverage_file(self, cfg: CIConfig) -> str:
178
+ return "coverage/lcov.info"
179
+
180
+ def reports(self, cfg: CIConfig) -> TestReports:
181
+ runner, coverage = cfg.test.runner, cfg.test.coverage
182
+ return TestReports(
183
+ junit="junit.xml" if runner in ("vitest", "bun", "jest") else "",
184
+ cobertura="coverage/cobertura-coverage.xml" if coverage and runner in ("vitest", "jest") else "",
185
+ # The "All files" row of the text coverage table.
186
+ coverage_regex=r"/All files\s*\|\s*(\d+(?:\.\d+)?)/" if coverage and runner in _COVERAGE_RUNNERS else "",
187
+ )
188
+
189
+ def audit(self, cfg: CIConfig) -> List[Item]:
190
+ return [step("Dependency vulnerability scan", run=_AUDIT[cfg.package_manager])]
191
+
192
+ def build(self, cfg: CIConfig) -> List[Item]:
193
+ return [*self.install(cfg), step("Build", run=cfg.build.command)]
194
+
195
+ def build_artifacts(self, det: Detection) -> List[str]:
196
+ # Vite, Angular, tsc, NestJS... build into dist/; Next.js' .next/ isn't something to ship as is.
197
+ return _BUILD_OUTPUT.get(det.framework or "", ["dist/"])
198
+
199
+ def dependabot_ecosystem(self, cfg: CIConfig) -> str:
200
+ return "bun" if cfg.package_manager == "bun" else "npm"
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Dict, List
5
+
6
+ from quoll.languages.base import SCRIPT_PREFIX, Detection
7
+ from quoll.languages.node.ci import run_script
8
+ from quoll.project import Project
9
+ from quoll.services import detect_services
10
+
11
+ # Used when the project doesn't say which version it targets.
12
+ NODE_DEFAULT = "24"
13
+ NODE_SUPPORTED = ("22", "24")
14
+ PNPM_DEFAULT = "10"
15
+
16
+ # Checked in order: the first lockfile found decides the package manager.
17
+ _LOCKFILES = (
18
+ ("pnpm-lock.yaml", "pnpm"),
19
+ ("bun.lock", "bun"),
20
+ ("bun.lockb", "bun"),
21
+ ("yarn.lock", "yarn"),
22
+ ("package-lock.json", "npm"),
23
+ )
24
+
25
+ # (frozen install, plain install)
26
+ _INSTALL = {
27
+ "npm": ("npm ci", "npm install"),
28
+ "pnpm": ("pnpm install --frozen-lockfile", "pnpm install"),
29
+ "yarn": ("yarn install --immutable", "yarn install"),
30
+ "yarn-classic": ("yarn install --frozen-lockfile", "yarn install"),
31
+ "bun": ("bun install --frozen-lockfile", "bun install"),
32
+ }
33
+
34
+ _NPM_PLACEHOLDER_TEST = "no test specified"
35
+
36
+ # Client libraries revealing a backing service.
37
+ _SERVICE_PACKAGES = {
38
+ "postgres": ("pg", "postgres", "pg-promise", "@vercel/postgres"),
39
+ "mysql": ("mysql2", "mysql"),
40
+ "redis": ("redis", "ioredis", "bullmq", "@upstash/redis"),
41
+ "mongodb": ("mongodb", "mongoose"),
42
+ }
43
+ # Prisma's datasource provider -> service.
44
+ _PRISMA_PROVIDERS = {"postgresql": "pg", "mysql": "mysql2", "mongodb": "mongodb"}
45
+
46
+
47
+ def detect(project: Project) -> Detection:
48
+ det = Detection()
49
+ pkg = package_json(project)
50
+ scripts = scripts_of(pkg)
51
+ deps = dependencies(pkg)
52
+
53
+ _package_manager(project, pkg.get("packageManager") or "", det)
54
+ det.install = _INSTALL[det.package_manager][0 if det.lockfile else 1]
55
+ if not det.lockfile:
56
+ det.cache = False
57
+ det.warnings.append(
58
+ "No lockfile found — CI installs will not be reproducible. "
59
+ "Commit your lockfile and run `quoll ci init` again."
60
+ )
61
+
62
+ _version(project, pkg, det)
63
+ det.lint_tools = _lint_tools(project, pkg, scripts, deps)
64
+ _test_runner(scripts, deps, det)
65
+ if "build" in scripts:
66
+ det.build_command = run_script(det.package_manager, "build")
67
+ det.services = detect_services([*deps, *_prisma_clients(project)], _SERVICE_PACKAGES)
68
+ return det
69
+
70
+
71
+ def _prisma_clients(project: Project) -> List[str]:
72
+ """The client package matching prisma/schema.prisma's provider, so Prisma projects get their database."""
73
+ m = re.search(r'provider\s*=\s*"(\w+)"', project.read_text("prisma/schema.prisma").split("datasource", 1)[-1])
74
+ return [_PRISMA_PROVIDERS[m.group(1)]] if m and m.group(1) in _PRISMA_PROVIDERS else []
75
+
76
+
77
+ def _package_manager(project: Project, declared: str, det: Detection) -> None:
78
+ for name, pm in _LOCKFILES:
79
+ if project.exists(name):
80
+ det.package_manager, det.lockfile = pm, name
81
+ break
82
+ else:
83
+ pm = declared.split("@")[0]
84
+ det.package_manager = pm if pm in ("pnpm", "yarn", "bun", "npm") else "npm"
85
+
86
+ if det.package_manager == "yarn":
87
+ major = re.match(r"yarn@(\d+)", declared)
88
+ classic = major.group(1) == "1" if major else not project.exists(".yarnrc.yml")
89
+ if classic:
90
+ det.package_manager = "yarn-classic"
91
+
92
+ if det.package_manager == "pnpm" and not declared.startswith("pnpm@"):
93
+ det.package_manager_version = PNPM_DEFAULT
94
+
95
+
96
+ def _version(project: Project, pkg: dict, det: Detection) -> None:
97
+ for name in (".nvmrc", ".node-version"):
98
+ if project.exists(name):
99
+ det.version_file = name
100
+ raw = project.read_text(name).strip().lstrip("v")
101
+ det.version = raw.split(".")[0] if raw[:1].isdigit() else NODE_DEFAULT
102
+ break
103
+ else:
104
+ m = re.search(r"(\d+)", (pkg.get("engines") or {}).get("node", ""))
105
+ det.version = m.group(1) if m else NODE_DEFAULT
106
+ low = int(det.version) if det.version.isdigit() else 0
107
+ det.matrix_versions = [v for v in NODE_SUPPORTED if int(v) >= low] or [det.version]
108
+
109
+
110
+ def _lint_tools(project: Project, pkg: dict, scripts: Dict[str, str], deps: dict) -> List[str]:
111
+ tools: List[str] = []
112
+ has_biome = "@biomejs/biome" in deps or project.glob_any("biome.json", "biome.jsonc")
113
+ if "lint" in scripts:
114
+ tools.append(f"{SCRIPT_PREFIX}lint")
115
+ elif has_biome:
116
+ tools.append("biome")
117
+ elif "eslint" in deps or project.glob_any("eslint.config.*", ".eslintrc*"):
118
+ tools.append("eslint")
119
+
120
+ if "format:check" in scripts:
121
+ tools.append(f"{SCRIPT_PREFIX}format:check")
122
+ elif not has_biome and (
123
+ "prettier" in deps or "prettier" in pkg or project.glob_any(".prettierrc*", "prettier.config.*")
124
+ ):
125
+ tools.append("prettier")
126
+
127
+ typecheck = next((s for s in ("typecheck", "type-check", "check-types") if s in scripts), None)
128
+ if typecheck:
129
+ tools.append(f"{SCRIPT_PREFIX}{typecheck}")
130
+ elif project.exists("tsconfig.json") and "typescript" in deps:
131
+ tools.append("tsc")
132
+ return tools
133
+
134
+
135
+ def _test_runner(scripts: Dict[str, str], deps: dict, det: Detection) -> None:
136
+ test_script = scripts.get("test", "")
137
+ if "vitest" in deps:
138
+ det.test_runner = "vitest"
139
+ det.coverage = any(p in deps for p in ("@vitest/coverage-v8", "@vitest/coverage-istanbul"))
140
+ if not det.coverage:
141
+ det.warnings.append("vitest coverage needs @vitest/coverage-v8 — coverage is disabled until you add it.")
142
+ elif "jest" in deps:
143
+ det.test_runner = "jest"
144
+ elif det.package_manager == "bun" and (not test_script or "bun test" in test_script):
145
+ det.test_runner = "bun"
146
+ elif test_script and _NPM_PLACEHOLDER_TEST not in test_script:
147
+ det.test_runner = f"{SCRIPT_PREFIX}test"
148
+ det.coverage = False
149
+ else:
150
+ det.warnings.append("No test runner found (vitest, jest or a `test` script) — the test job is disabled.")
151
+
152
+
153
+ def package_json(project: Project) -> dict:
154
+ pkg = project.read_json("package.json")
155
+ return pkg if isinstance(pkg, dict) else {}
156
+
157
+
158
+ def dependencies(pkg: dict) -> Dict[str, str]:
159
+ """`dependencies` and `devDependencies`, merged."""
160
+ return {**(pkg.get("dependencies") or {}), **(pkg.get("devDependencies") or {})}
161
+
162
+
163
+ def scripts_of(pkg: dict) -> Dict[str, str]:
164
+ return pkg.get("scripts") or {}
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
4
+
5
+ from quoll.languages.base import Detection, DockerDefaults, DockerImage
6
+ from quoll.languages.node.detect import dependencies, package_json, scripts_of
7
+ from quoll.project import Project
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.docker.config import DockerConfig
11
+
12
+ PNPM_DEFAULT = "10"
13
+
14
+ # (frozen install, plain install)
15
+ _INSTALL = {
16
+ "npm": ("npm ci", "npm install"),
17
+ "pnpm": ("pnpm install --frozen-lockfile", "pnpm install"),
18
+ "yarn": ("yarn install --immutable", "yarn install"),
19
+ "yarn-classic": ("yarn install --frozen-lockfile", "yarn install"),
20
+ "bun": ("bun install --frozen-lockfile", "bun install"),
21
+ }
22
+ # Removes devDependencies after the build. Yarn Berry has no equivalent without a plugin.
23
+ _PRUNE = {
24
+ "npm": "npm prune --omit=dev",
25
+ "pnpm": "pnpm prune --prod",
26
+ "yarn-classic": "yarn install --production --ignore-scripts --prefer-offline",
27
+ "bun": "rm -rf node_modules && bun install --production --ignore-scripts",
28
+ }
29
+ _CACHE = {
30
+ "npm": "/root/.npm",
31
+ "pnpm": "/root/.local/share/pnpm/store",
32
+ "yarn": "/root/.yarn/berry/cache",
33
+ "yarn-classic": "/usr/local/share/.cache/yarn",
34
+ "bun": "/root/.bun/install/cache",
35
+ }
36
+ _LOCKFILES = {
37
+ "npm": ("package-lock.json",),
38
+ "pnpm": ("pnpm-lock.yaml",),
39
+ "yarn": ("yarn.lock",),
40
+ "yarn-classic": ("yarn.lock",),
41
+ "bun": ("bun.lock", "bun.lockb"),
42
+ }
43
+ # Files and directories the install reads besides package.json and the lockfile.
44
+ _INSTALL_FILES = (".npmrc", "pnpm-workspace.yaml", ".yarnrc.yml", ".yarnrc", "bunfig.toml")
45
+ _INSTALL_DIRS = (".yarn/releases", ".yarn/plugins", ".yarn/patches", "patches")
46
+ # A Vite project without one of these is a static single-page app.
47
+ _SERVER_DEPS = {"express", "fastify", "koa", "hono", "@hono/node-server", "@nestjs/core", "next", "nuxt"}
48
+
49
+
50
+ class NodeImage(DockerImage):
51
+ # Static sites: unknown paths fall back to index.html (single-page apps) or to 404.html.
52
+ spa = True
53
+
54
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
55
+ pkg = package_json(project)
56
+ deps = dependencies(pkg)
57
+ scripts = scripts_of(pkg)
58
+ if "vite" in deps and det.build_command and "start" not in scripts and not _SERVER_DEPS & deps.keys():
59
+ return DockerDefaults(port=8080, build_command=det.build_command, output_dir="dist", static=True)
60
+ command = _start_command(project, pkg, scripts)
61
+ warnings = [] if command else ["Couldn't infer how to start the app — set docker.command in quoll.toml."]
62
+ return DockerDefaults(port=3000, command=command, build_command=det.build_command, warnings=warnings)
63
+
64
+ def context(self, project: Project, cfg: DockerConfig) -> Dict[str, Any]:
65
+ pm = cfg.package_manager
66
+ lockfile = next((f for f in _LOCKFILES[pm] if project.exists(f)), "")
67
+ bun = pm == "bun"
68
+ return {
69
+ "build_image": "oven/bun:1" if bun else f"node:{cfg.version}-slim",
70
+ "runtime_image": "oven/bun:1-slim" if bun else f"node:{cfg.version}-slim",
71
+ "user": "bun" if bun else "node",
72
+ # Both images' user has uid and gid 1000.
73
+ "uid": "1000:1000",
74
+ "setup": _setup(project, cfg),
75
+ "install": _INSTALL[pm][0 if lockfile else 1],
76
+ "install_files": ["package.json", *([lockfile] if lockfile else []), *_present(project, _INSTALL_FILES)],
77
+ "install_dirs": _present(project, _INSTALL_DIRS),
78
+ "cache": _CACHE[pm],
79
+ "prune": _PRUNE.get(pm, ""),
80
+ "spa": self.spa,
81
+ "build_env": self.build_env(cfg),
82
+ "env": {"NODE_ENV": "production", "PORT": str(cfg.port), "HOST": "0.0.0.0", **self.env(cfg)},
83
+ }
84
+
85
+ def writable_paths(self, cfg: DockerConfig) -> Optional[List[str]]:
86
+ if not cfg.static:
87
+ return ["/tmp"]
88
+ # nginx's temporary files; with a proxy, the configuration its entrypoint writes from the template.
89
+ return ["/tmp", "/var/cache/nginx", *(["/etc/nginx/conf.d"] if cfg.proxy else [])]
90
+
91
+ def build_env(self, cfg: DockerConfig) -> Dict[str, str]:
92
+ """Environment of the build step."""
93
+ return {}
94
+
95
+ def env(self, cfg: DockerConfig) -> Dict[str, str]:
96
+ """Extra runtime environment variables."""
97
+ return {}
98
+
99
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
100
+ return [
101
+ "**/node_modules",
102
+ ".next",
103
+ ".nuxt",
104
+ ".output",
105
+ ".svelte-kit",
106
+ ".astro",
107
+ ".angular",
108
+ "dist",
109
+ "build",
110
+ "coverage",
111
+ "npm-debug.log*",
112
+ ".env",
113
+ ".env.*",
114
+ "!.env.example",
115
+ ]
116
+
117
+
118
+ def _setup(project: Project, cfg: DockerConfig) -> str:
119
+ """Command installing the package manager in the build image."""
120
+ declared = str(package_json(project).get("packageManager", "")).split("+")[0]
121
+ if cfg.package_manager == "pnpm":
122
+ version = declared.split("@")[1] if declared.startswith("pnpm@") else cfg.package_manager_version
123
+ return f"npm install --global pnpm@{version or PNPM_DEFAULT}"
124
+ if cfg.package_manager == "yarn":
125
+ return "corepack enable"
126
+ return ""
127
+
128
+
129
+ def _start_command(project: Project, pkg: dict, scripts: Dict[str, str]) -> str:
130
+ if scripts.get("start"):
131
+ return scripts["start"]
132
+ if pkg.get("main"):
133
+ return f"node {pkg['main']}"
134
+ for name in ("server.js", "index.js", "src/server.js", "src/index.js"):
135
+ if project.exists(name):
136
+ return f"node {name}"
137
+ return ""
138
+
139
+
140
+ def _present(project: Project, names: tuple) -> List[str]:
141
+ return [n for n in names if project.exists(n)]
@@ -0,0 +1 @@
1
+ """Node.js frameworks."""
@@ -0,0 +1,18 @@
1
+ """Angular: tests run once through the CLI (headless Chrome for Karma); opt out of CLI analytics."""
2
+
3
+ from quoll.languages.base import Framework
4
+ from quoll.languages.node.frameworks.angular.ci import AngularCI
5
+ from quoll.languages.node.frameworks.angular.detect import detect
6
+ from quoll.languages.node.frameworks.angular.docker import AngularImage
7
+ from quoll.languages.node.frameworks.angular.starter import starter
8
+
9
+ framework = Framework(
10
+ name="angular",
11
+ language="node",
12
+ detect=detect,
13
+ ci=AngularCI(),
14
+ # "angular" is the CLI's Vitest-based runner (Angular 21+), "angular-karma" the older Karma one.
15
+ test_runners=("angular", "angular-karma"),
16
+ docker=AngularImage(),
17
+ starters=(starter,),
18
+ )
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Dict, List
4
+
5
+ from quoll.ci.steps import Item, step
6
+ from quoll.languages.base import FrameworkCI
7
+
8
+ if TYPE_CHECKING:
9
+ from quoll.ci.config import CIConfig
10
+
11
+ # Karma drives a real Chrome. Runners have one; Node images don't: install Chromium, through a launcher adding
12
+ # --no-sandbox (container jobs run as root, where Chrome's sandbox can't start).
13
+ _CHROME = """if [ -z "${CHROME_BIN:-}" ] && ! command -v google-chrome >/dev/null 2>&1; then
14
+ apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium >/dev/null
15
+ printf '#!/bin/sh\\nexec chromium --no-sandbox "$@"\\n' > /usr/local/bin/chromium-ci
16
+ chmod +x /usr/local/bin/chromium-ci
17
+ echo "CHROME_BIN=/usr/local/bin/chromium-ci" >> "${GITHUB_ENV:-/dev/null}"
18
+ fi"""
19
+
20
+
21
+ class AngularCI(FrameworkCI):
22
+ def env(self, cfg: CIConfig) -> Dict[str, str]:
23
+ return {"NG_CLI_ANALYTICS": "false"}
24
+
25
+ def test(self, cfg: CIConfig) -> List[Item]:
26
+ ci = cfg.language.ci
27
+ cmd = ci.exec(cfg, "ng test --watch=false")
28
+ steps: List[Item] = list(ci.install(cfg))
29
+ if cfg.test.runner == "angular-karma":
30
+ cmd += " --browsers=ChromeHeadless"
31
+ steps.append(step("Chrome for Karma", run=_CHROME))
32
+ # Same shell on container platforms: the launcher installed above, if any.
33
+ cmd = f'if [ -x /usr/local/bin/chromium-ci ]; then export CHROME_BIN=/usr/local/bin/chromium-ci; fi\n{cmd}'
34
+ return [*steps, step("Tests", run=cmd, secrets=cfg.test.env_secrets)]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection
4
+ from quoll.languages.node.detect import dependencies, package_json, scripts_of
5
+ from quoll.project import Project
6
+
7
+
8
+ def detect(project: Project, det: Detection) -> bool:
9
+ pkg = package_json(project)
10
+ deps = dependencies(pkg)
11
+ if "@angular/core" not in deps:
12
+ return False
13
+ # `ng build` type-checks, and Angular's solution-style tsconfig.json makes plain tsc a no-op.
14
+ det.lint_tools = [t for t in det.lint_tools if t != "tsc"]
15
+ if "ng test" in scripts_of(pkg).get("test", ""):
16
+ det.test_runner = "angular-karma" if "karma" in deps else "angular"
17
+ # Coverage thresholds live in karma.conf.js / angular.json.
18
+ det.coverage = False
19
+ det.warnings = [w for w in det.warnings if "vitest coverage" not in w]
20
+ return True
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection, DockerDefaults
4
+ from quoll.languages.node.detect import dependencies, package_json
5
+ from quoll.languages.node.docker import NodeImage
6
+ from quoll.project import Project
7
+
8
+
9
+ class AngularImage(NodeImage):
10
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
11
+ build = det.build_command or "npm run build"
12
+ output, application_builder, ssr = _build_target(project)
13
+ if ssr and "@angular/ssr" in dependencies(package_json(project)):
14
+ return DockerDefaults(port=4000, command=f"node {output}/server/server.mjs", build_command=build)
15
+ # The application builder writes the browser bundle to <outputPath>/browser.
16
+ site = f"{output}/browser" if application_builder else output
17
+ return DockerDefaults(port=8080, build_command=build, output_dir=site, static=True)
18
+
19
+
20
+ def _build_target(project: Project):
21
+ """(outputPath, uses the application builder, has SSR) of the first project in angular.json."""
22
+ workspace = project.read_json("angular.json")
23
+ projects = workspace.get("projects", {}) if isinstance(workspace, dict) else {}
24
+ name, config = next(iter(projects.items()), (project.name, {}))
25
+ build = config.get("architect", {}).get("build", {})
26
+ options = build.get("options", {})
27
+ output = options.get("outputPath", f"dist/{name}")
28
+ if isinstance(output, dict):
29
+ output = output.get("base", f"dist/{name}")
30
+ application_builder = str(build.get("builder", ":application")).endswith(":application")
31
+ return output, application_builder, bool(options.get("ssr") or options.get("server"))