setupdoctor 0.1.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.
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: setupdoctor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read a repo's setup requirements from its code and check them against your machine.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# setupdoctor
|
|
12
|
+
|
|
13
|
+
`brew doctor` for any repo. It reads what a project needs from its code and config files, checks your machine, and prints the fix for each gap. It never reads the README.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
$ setupdoctor
|
|
17
|
+
setupdoctor: 5 checks
|
|
18
|
+
|
|
19
|
+
✓ Node >=12.0.0 required (package.json engines), found 26.10.0
|
|
20
|
+
✗ yarn is this repo's package manager but is not installed
|
|
21
|
+
fix: npm install -g yarn
|
|
22
|
+
✗ Node dependencies not installed (no node_modules folder)
|
|
23
|
+
fix: yarn install
|
|
24
|
+
✗ No .env file; .env.example defines 12 variable(s)
|
|
25
|
+
fix: cp .env.example .env, then fill in the values
|
|
26
|
+
✗ MongoDB (docker-compose.yml) is not reachable on localhost:27017
|
|
27
|
+
fix: docker compose up -d
|
|
28
|
+
|
|
29
|
+
4 failed, 0 warning(s), 1 passed
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That is real output from a fresh clone of `hagopj13/node-express-boilerplate`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Not on PyPI yet. From a copy of this folder:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pip install .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It is one file with no dependencies, so `python setupdoctor.py` also works.
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
setupdoctor # check the current folder
|
|
48
|
+
setupdoctor path/to/repo
|
|
49
|
+
setupdoctor --ci # for pipelines, see below
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The exit code is 1 if any check failed. A check only reports; it never changes anything on your machine.
|
|
53
|
+
|
|
54
|
+
## Run it automatically after every clone
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
setupdoctor --install-hook
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Do this once. From then on, every `git clone` prints the checklist for the new repo as soon as the clone finishes. It stays silent on repos with nothing to check and when you switch branches.
|
|
61
|
+
|
|
62
|
+
This is the one command that changes your machine: it adds a `post-checkout` hook to git's template folder and points `init.templateDir` in your global git config at it. Repos you cloned earlier are not affected. Undo it with `setupdoctor --uninstall-hook`.
|
|
63
|
+
|
|
64
|
+
## What it checks
|
|
65
|
+
|
|
66
|
+
| Check | Read from |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Node version | `.nvmrc`, `.node-version`, `package.json` engines |
|
|
69
|
+
| Python version | `.python-version`, `runtime.txt`, `pyproject.toml` |
|
|
70
|
+
| Package manager is installed | lockfile or `packageManager` (pnpm, yarn, bun, uv, poetry, pipenv) |
|
|
71
|
+
| Dependencies are installed | `node_modules`; `.venv` or `venv` |
|
|
72
|
+
| Env vars are set | `.env.example`, plus reads in the source that break when the variable is unset |
|
|
73
|
+
| Env vars are documented | every env var the source reads, compared with `.env.example` |
|
|
74
|
+
| Services are running | Postgres, Redis, MongoDB and MySQL ports in the compose file |
|
|
75
|
+
|
|
76
|
+
It only fails a check when the repo declares the requirement. For environment variables that means one of:
|
|
77
|
+
|
|
78
|
+
- the variable is in `.env.example` and you have no `.env`
|
|
79
|
+
- Python reads it as `os.environ["X"]` outside any `if` or `try`
|
|
80
|
+
- TypeScript reads it as `process.env.X!`
|
|
81
|
+
|
|
82
|
+
Softer reads such as `os.getenv("X")` or a bare `process.env.X` are treated as settings the app can run without. On real projects they nearly always are, and reporting them buried the real problems under dozens of false alarms. Tests, docs, examples and installed packages are not scanned.
|
|
83
|
+
|
|
84
|
+
## CI mode
|
|
85
|
+
|
|
86
|
+
`setupdoctor --ci` ignores the machine and fails if the code reads an environment variable that is not in `.env.example`. Run it on every pull request and the example file cannot fall behind the code.
|
|
87
|
+
|
|
88
|
+
## Limits
|
|
89
|
+
|
|
90
|
+
- Node and Python only.
|
|
91
|
+
- It checks the folder you point it at. In a monorepo, run it in each project folder.
|
|
92
|
+
- Env var names built at runtime, or read through a settings library (Joi, pydantic-settings, django-environ), are not seen by the scan. The `.env.example` check still covers them.
|
|
93
|
+
- A JavaScript project with no `.env.example` gets no env var checks, because nothing in plain JavaScript marks a variable as required.
|
|
94
|
+
- A database is only checked if the compose file publishes its port. One you are expected to install yourself is not detected.
|
|
95
|
+
- Compose files are read by pattern, so ports written as `${VAR:-5432}:5432` are missed.
|
|
96
|
+
- A missing Python virtualenv is a warning, because poetry and pipenv keep theirs outside the repo.
|
|
97
|
+
- Anything that lives outside the code, such as VPN access or an account someone has to create for you, cannot be detected.
|
|
98
|
+
|
|
99
|
+
## Tests
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
python test_setupdoctor.py
|
|
103
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
setupdoctor.py,sha256=tGqWpyCIsBNaHYN7C4j3Djxsjf_6b2t8lQKB01klp5U,20200
|
|
2
|
+
setupdoctor-0.1.0.dist-info/licenses/LICENSE,sha256=Gv_gpC86tYoeeqY4tDjzFTM1QOkrVLIL2Il7qzAf6b8,1094
|
|
3
|
+
setupdoctor-0.1.0.dist-info/METADATA,sha256=i6k7uW9wuX2mh_Yf1Sf8YLAh08KvXEPalribQxEf6Lk,4478
|
|
4
|
+
setupdoctor-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
setupdoctor-0.1.0.dist-info/entry_points.txt,sha256=cCwBFsVwvHNoat4B-h_sMTXq8hkM3c6Vsf8fYrBQpB0,49
|
|
6
|
+
setupdoctor-0.1.0.dist-info/top_level.txt,sha256=SfN9XNiwYO66s8kYS0Gud_r1e89E9KUWTbSZ5hZcNPo,12
|
|
7
|
+
setupdoctor-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zamudiog1221-ops
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
setupdoctor
|
setupdoctor.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""setupdoctor: read a repo's setup requirements from its code and check them against this machine."""
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import warnings
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
OK, FAIL, WARN = "ok", "fail", "warn"
|
|
16
|
+
MARK = {OK: ("✓", "32"), FAIL: ("✗", "31"), WARN: ("!", "33")}
|
|
17
|
+
|
|
18
|
+
SKIP_DIRS = {"node_modules", "venv", "env", "dist", "build", "out", "target", "vendor", "coverage",
|
|
19
|
+
"__pycache__", "site-packages", "test", "tests", "__tests__", "e2e", "docs", "examples"}
|
|
20
|
+
SKIP_FILE = re.compile(r"(^test_|^conftest\.|_test\.|\.test\.|\.spec\.|\.min\.|^(playwright|jest|vitest|cypress)\.config\.)")
|
|
21
|
+
JS_EXT = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
|
|
22
|
+
|
|
23
|
+
# Variables the shell, the runtime or the host platform sets; nobody puts these in .env.
|
|
24
|
+
AMBIENT = {"NODE_ENV", "PATH", "HOME", "USER", "PWD", "SHELL", "TERM", "LANG", "TZ", "EDITOR", "CI",
|
|
25
|
+
"TMPDIR", "TEMP", "TMP", "APPDATA", "LOCALAPPDATA", "USERPROFILE", "HOSTNAME", "HOST", "PORT",
|
|
26
|
+
"DEBUG", "PYTHONPATH", "VIRTUAL_ENV", "NO_COLOR", "FORCE_COLOR", "VERCEL", "NETLIFY",
|
|
27
|
+
"MODE", "DEV", "PROD", "SSR", "BASE_URL", "PROGRAMFILES", "SYSTEMROOT", "WINDIR", "COMSPEC", "OS"}
|
|
28
|
+
AMBIENT_PREFIX = ("npm_", "GITHUB_", "VERCEL_", "XDG_", "RUNNER_")
|
|
29
|
+
|
|
30
|
+
# A read is "hard" when the code breaks without the variable: an unconditional os.environ["X"], or
|
|
31
|
+
# TypeScript's process.env.X! assertion. It is "optional" when the code copes: a fallback, a ternary
|
|
32
|
+
# or && test, or an assignment. Everything else (os.getenv("X"), a bare process.env.X, any read inside
|
|
33
|
+
# an if or try) is treated as a setting the app can run without, because on real repos that is what
|
|
34
|
+
# it nearly always is.
|
|
35
|
+
JS_ENV = re.compile(r"""(?:process\.env|import\.meta\.env)(?:\.([A-Z][A-Z0-9_]*)|\[\s*['"](\w+)['"]\s*\])"""
|
|
36
|
+
r"""(!(?!=))?(\s*(?:\|\||\?|&&|=(?!=)))?""")
|
|
37
|
+
JS_DESTRUCT = re.compile(r"\{([^}]*)\}\s*=\s*process\.env\b(?!\s*[.\[])")
|
|
38
|
+
|
|
39
|
+
ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local"]
|
|
40
|
+
ENV_EXAMPLES = [".env.example", ".env.sample", ".env.template", ".env.dist"]
|
|
41
|
+
|
|
42
|
+
NODE_LOCKS = [("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lockb", "bun"), ("bun.lock", "bun")]
|
|
43
|
+
PY_LOCKS = [("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile", "pipenv")]
|
|
44
|
+
INSTALL = {"pnpm": "npm install -g pnpm", "yarn": "npm install -g yarn", "bun": "npm install -g bun",
|
|
45
|
+
"uv": "pip install uv", "poetry": "pipx install poetry", "pipenv": "pip install pipenv"}
|
|
46
|
+
|
|
47
|
+
SERVICES = {5432: "Postgres", 6379: "Redis", 27017: "MongoDB", 3306: "MySQL"}
|
|
48
|
+
# ponytail: pattern match, not a YAML parser; misses ports written as ${VAR:-5432}:5432. Parse YAML if that bites.
|
|
49
|
+
COMPOSE_PORT = re.compile(r"""(?m)^\s*-\s*["']?(?:[\d.]+:)?(\d+):(\d+)(?:/tcp)?["']?\s*$""")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read(path):
|
|
53
|
+
try:
|
|
54
|
+
return Path(path).read_text(encoding="utf-8", errors="replace")
|
|
55
|
+
except OSError:
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def first_line(path):
|
|
60
|
+
return (read(path).strip().splitlines() or [""])[0].strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def version(text):
|
|
64
|
+
m = re.search(r"\d+(?:\.\d+)*", text or "")
|
|
65
|
+
return tuple(int(p) for p in m.group().split(".")) if m else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def satisfies(found, spec, n):
|
|
69
|
+
"""True if version tuple `found` meets `spec`. A bare version pins its first n parts."""
|
|
70
|
+
found = tuple(found) + (0, 0)
|
|
71
|
+
|
|
72
|
+
def part_ok(part):
|
|
73
|
+
req = version(part)
|
|
74
|
+
if not req:
|
|
75
|
+
return True
|
|
76
|
+
op = re.match(r"[<>=^~!]*", part).group()
|
|
77
|
+
if op in (">=", ">"):
|
|
78
|
+
return found >= req
|
|
79
|
+
if op == "<":
|
|
80
|
+
return found[:len(req)] < req
|
|
81
|
+
if op == "<=":
|
|
82
|
+
return found[:len(req)] <= req
|
|
83
|
+
if op == "!=":
|
|
84
|
+
return True
|
|
85
|
+
if op in ("^", "~", "~="): # ponytail: same major and not older; tighter ~ rules not modelled
|
|
86
|
+
return found[0] == req[0] and found >= req
|
|
87
|
+
k = min(n, len(req))
|
|
88
|
+
return found[:k] == req[:k]
|
|
89
|
+
|
|
90
|
+
alts = [re.sub(r"([<>=^~!])\s+", r"\1", a) for a in spec.split("||")]
|
|
91
|
+
return any(all(part_ok(p) for p in re.split(r"[,\s]+", a) if p) for a in alts if version(a))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_version(*cmds):
|
|
95
|
+
"""Version tuple of the first of `cmds` found on PATH, or None."""
|
|
96
|
+
for cmd in cmds:
|
|
97
|
+
exe = shutil.which(cmd)
|
|
98
|
+
if not exe:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=10)
|
|
102
|
+
except (OSError, subprocess.SubprocessError):
|
|
103
|
+
continue
|
|
104
|
+
found = version(out.stdout + out.stderr)
|
|
105
|
+
if found:
|
|
106
|
+
return found
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def on_path(cmd):
|
|
111
|
+
return shutil.which(cmd) is not None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def port_open(port):
|
|
115
|
+
try:
|
|
116
|
+
socket.create_connection(("localhost", port), timeout=0.5).close()
|
|
117
|
+
return True
|
|
118
|
+
except OSError:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def js_env_uses(text):
|
|
123
|
+
"""Yield (name, line number, optional, hard) for each env var a JS/TS file reads."""
|
|
124
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
125
|
+
# long lines are minified or generated code
|
|
126
|
+
if "env" not in line or len(line) > 2000 or line.lstrip().startswith(("//", "*")):
|
|
127
|
+
continue
|
|
128
|
+
for m in JS_ENV.finditer(line):
|
|
129
|
+
yield m.group(1) or m.group(2), n, bool(m.group(4)), bool(m.group(3))
|
|
130
|
+
for m in JS_DESTRUCT.finditer(line):
|
|
131
|
+
for item in m.group(1).split(","):
|
|
132
|
+
name = re.match(r"\s*(\w+)", item)
|
|
133
|
+
if name:
|
|
134
|
+
yield name.group(1), n, "=" in item, False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def py_env_uses(text):
|
|
138
|
+
"""Return (name, line number, optional, hard) for each env var a Python file reads."""
|
|
139
|
+
try:
|
|
140
|
+
with warnings.catch_warnings():
|
|
141
|
+
warnings.simplefilter("ignore")
|
|
142
|
+
tree = ast.parse(text)
|
|
143
|
+
except (SyntaxError, ValueError, RecursionError):
|
|
144
|
+
return [] # a template, or syntax this Python can't read: nothing can be said safely
|
|
145
|
+
|
|
146
|
+
def is_environ(node):
|
|
147
|
+
return (isinstance(node, ast.Attribute) and node.attr == "environ") or \
|
|
148
|
+
(isinstance(node, ast.Name) and node.id == "environ")
|
|
149
|
+
|
|
150
|
+
def text_of(node):
|
|
151
|
+
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
|
|
152
|
+
|
|
153
|
+
uses, stack = [], [(tree, False, False)]
|
|
154
|
+
while stack:
|
|
155
|
+
node, guarded, in_or = stack.pop()
|
|
156
|
+
if isinstance(node, ast.Subscript) and is_environ(node.value) and text_of(node.slice) \
|
|
157
|
+
and isinstance(node.ctx, ast.Load):
|
|
158
|
+
uses.append((text_of(node.slice), node.lineno, False, not guarded))
|
|
159
|
+
elif isinstance(node, ast.Call) and node.args and text_of(node.args[0]):
|
|
160
|
+
f = node.func
|
|
161
|
+
if (isinstance(f, ast.Name) and f.id == "getenv") or (isinstance(f, ast.Attribute) and (
|
|
162
|
+
f.attr == "getenv" or (f.attr == "get" and is_environ(f.value)))):
|
|
163
|
+
has_default = len(node.args) > 1 or bool(node.keywords) or in_or
|
|
164
|
+
uses.append((text_of(node.args[0]), node.lineno, has_default, False))
|
|
165
|
+
guarded = guarded or isinstance(node, (ast.If, ast.IfExp, ast.Try))
|
|
166
|
+
is_or = isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or)
|
|
167
|
+
stack.extend((child, guarded, is_or) for child in ast.iter_child_nodes(node))
|
|
168
|
+
return sorted(uses, key=lambda use: use[1])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def scan_env(root):
|
|
172
|
+
"""Map each env var the source reads without a fallback to ('file:line' of first use, hard)."""
|
|
173
|
+
reads = {}
|
|
174
|
+
for dirpath, dirs, files in os.walk(root):
|
|
175
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
|
|
176
|
+
for name in sorted(files):
|
|
177
|
+
ext = os.path.splitext(name)[1]
|
|
178
|
+
if (ext not in JS_EXT and ext != ".py") or SKIP_FILE.search(name):
|
|
179
|
+
continue
|
|
180
|
+
path = Path(dirpath, name)
|
|
181
|
+
try:
|
|
182
|
+
if path.stat().st_size > 1_000_000:
|
|
183
|
+
continue
|
|
184
|
+
except OSError:
|
|
185
|
+
continue
|
|
186
|
+
text = read(path)
|
|
187
|
+
if "env" not in text:
|
|
188
|
+
continue
|
|
189
|
+
for var, n, optional, hard in (py_env_uses if ext == ".py" else js_env_uses)(text):
|
|
190
|
+
if optional or var in AMBIENT or var.startswith(AMBIENT_PREFIX):
|
|
191
|
+
continue
|
|
192
|
+
if var not in reads or (hard and not reads[var][1]):
|
|
193
|
+
reads[var] = (f"{path.relative_to(root).as_posix()}:{n}", hard)
|
|
194
|
+
return reads
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def env_keys(root, names, commented=False):
|
|
198
|
+
"""Variable names defined in the given env files; `commented` also counts '# KEY=' lines."""
|
|
199
|
+
rx = re.compile(r"^\s*%s(?:export\s+)?(\w+)\s*=" % (r"#?\s*" if commented else ""), re.M)
|
|
200
|
+
return {key for name in names for key in rx.findall(read(root / name))}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_version(label, found, spec, source, n, fix):
|
|
204
|
+
need = f"{label} {spec} required ({source})"
|
|
205
|
+
if not found:
|
|
206
|
+
return FAIL, f"{need}, but {label} was not found on PATH", fix
|
|
207
|
+
shown = ".".join(map(str, found))
|
|
208
|
+
if satisfies(found, spec, n):
|
|
209
|
+
return OK, f"{need}, found {shown}", None
|
|
210
|
+
return FAIL, f"{need}, found {shown}", fix
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def diagnose(root, ci=False, env=os.environ):
|
|
214
|
+
"""Return a list of (status, message, fix) for the repo at `root`."""
|
|
215
|
+
root = Path(root)
|
|
216
|
+
checks = []
|
|
217
|
+
|
|
218
|
+
def add(*check):
|
|
219
|
+
checks.append(check)
|
|
220
|
+
|
|
221
|
+
reads = scan_env(root)
|
|
222
|
+
example = next((n for n in ENV_EXAMPLES if (root / n).exists()), None)
|
|
223
|
+
documented = env_keys(root, ENV_EXAMPLES, commented=True)
|
|
224
|
+
undocumented = sorted(v for v in reads if v not in documented)
|
|
225
|
+
|
|
226
|
+
if ci:
|
|
227
|
+
for var in undocumented:
|
|
228
|
+
add(FAIL, f"{var} is read at {reads[var][0]} but is not in {example or '.env.example'}",
|
|
229
|
+
f"add {var}= to {example or '.env.example'}")
|
|
230
|
+
if reads and not undocumented:
|
|
231
|
+
add(OK, f"All {len(reads)} environment variables are documented in {example}", None)
|
|
232
|
+
return checks
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
pkg = json.loads(read(root / "package.json") or "{}")
|
|
236
|
+
except ValueError:
|
|
237
|
+
pkg = {}
|
|
238
|
+
if not isinstance(pkg, dict):
|
|
239
|
+
pkg = {}
|
|
240
|
+
pyproject = read(root / "pyproject.toml")
|
|
241
|
+
is_node = (root / "package.json").exists()
|
|
242
|
+
is_py = (any((root / f).exists() for f in ("requirements.txt", "Pipfile", "setup.py", "uv.lock",
|
|
243
|
+
"poetry.lock", ".python-version"))
|
|
244
|
+
or bool(re.search(r"(?m)^\[(project|tool\.poetry)\]", pyproject)))
|
|
245
|
+
|
|
246
|
+
if is_node:
|
|
247
|
+
node = run_version("node")
|
|
248
|
+
engines = pkg.get("engines")
|
|
249
|
+
specs = [(".nvmrc", first_line(root / ".nvmrc")), (".node-version", first_line(root / ".node-version")),
|
|
250
|
+
("package.json engines", str(engines.get("node", "")) if isinstance(engines, dict) else "")]
|
|
251
|
+
for source, spec in specs:
|
|
252
|
+
if version(spec):
|
|
253
|
+
add(*check_version("Node", node, spec, source, 1, f"nvm install {version(spec)[0]}"))
|
|
254
|
+
break
|
|
255
|
+
else:
|
|
256
|
+
if not node:
|
|
257
|
+
add(FAIL, "Node is needed (package.json) but was not found on PATH",
|
|
258
|
+
"install Node from https://nodejs.org")
|
|
259
|
+
pm = (str(pkg.get("packageManager") or "").split("@")[0]
|
|
260
|
+
or next((p for f, p in NODE_LOCKS if (root / f).exists()), "npm"))
|
|
261
|
+
if pm != "npm" and not on_path(pm):
|
|
262
|
+
add(FAIL, f"{pm} is this repo's package manager but is not installed", INSTALL.get(pm))
|
|
263
|
+
if pkg.get("dependencies") or pkg.get("devDependencies"):
|
|
264
|
+
if (root / "node_modules").is_dir():
|
|
265
|
+
add(OK, "Node dependencies installed (node_modules exists)", None)
|
|
266
|
+
else:
|
|
267
|
+
add(FAIL, "Node dependencies not installed (no node_modules folder)", f"{pm} install")
|
|
268
|
+
|
|
269
|
+
if is_py:
|
|
270
|
+
pm = next((p for f, p in PY_LOCKS if (root / f).exists()), None)
|
|
271
|
+
py_spec = re.search(r"""(?m)^(?:requires-python|python)\s*=\s*["']([^"']+)""", pyproject)
|
|
272
|
+
specs = [(".python-version", first_line(root / ".python-version")),
|
|
273
|
+
("runtime.txt", first_line(root / "runtime.txt")),
|
|
274
|
+
("pyproject.toml", py_spec.group(1) if py_spec else "")]
|
|
275
|
+
if pm != "uv": # uv downloads the right Python by itself
|
|
276
|
+
python = run_version("python", "python3") or tuple(sys.version_info[:3])
|
|
277
|
+
for source, spec in specs:
|
|
278
|
+
if version(spec):
|
|
279
|
+
want = ".".join(map(str, version(spec)[:2]))
|
|
280
|
+
add(*check_version("Python", python, spec, source, 2,
|
|
281
|
+
f"install Python {want} (for example: pyenv install {want})"))
|
|
282
|
+
break
|
|
283
|
+
if pm and not on_path(pm):
|
|
284
|
+
add(FAIL, f"{pm} is this repo's package manager but is not installed", INSTALL.get(pm))
|
|
285
|
+
# poetry and pipenv keep virtualenvs outside the repo, so there's nothing to look for.
|
|
286
|
+
if pm in (None, "uv") and not env.get("VIRTUAL_ENV") \
|
|
287
|
+
and not any((root / d).is_dir() for d in (".venv", "venv", "env")):
|
|
288
|
+
install = ("uv sync" if pm == "uv" else "python -m venv .venv, activate it, then pip install "
|
|
289
|
+
+ ("-r requirements.txt" if (root / "requirements.txt").exists() else "-e ."))
|
|
290
|
+
add(WARN, "No virtual environment found (.venv or venv), so dependencies are probably not installed",
|
|
291
|
+
install)
|
|
292
|
+
|
|
293
|
+
set_keys = env_keys(root, ENV_FILES)
|
|
294
|
+
required = [v for v, (_, hard) in reads.items() if hard]
|
|
295
|
+
missing = sorted(v for v in required if v not in env and v not in set_keys)
|
|
296
|
+
if required and not missing:
|
|
297
|
+
add(OK, f"All {len(required)} required environment variables are set", None)
|
|
298
|
+
# .env.example is a declared requirement even when the code reads env vars in a way the scan can't see.
|
|
299
|
+
declared_unset = sorted(k for k in env_keys(root, ENV_EXAMPLES) if k not in env and k not in set_keys)
|
|
300
|
+
if declared_unset and not any((root / n).exists() for n in ENV_FILES):
|
|
301
|
+
add(FAIL, f"No .env file; {example} defines {len(declared_unset)} variable(s)",
|
|
302
|
+
f"cp {example} .env, then fill in the values")
|
|
303
|
+
missing = [v for v in missing if v not in documented]
|
|
304
|
+
elif set(declared_unset) - set(missing):
|
|
305
|
+
add(WARN, f"In {example} but not in your .env: " + ", ".join(sorted(set(declared_unset) - set(missing))),
|
|
306
|
+
f"copy them over from {example} if you need them")
|
|
307
|
+
for var in missing:
|
|
308
|
+
add(FAIL, f"{var} is not set (first used in {reads[var][0]})", f"add {var}=... to .env")
|
|
309
|
+
# Only worth a line when the repo keeps an example file and it has fallen behind the code.
|
|
310
|
+
if undocumented and example:
|
|
311
|
+
shown = ", ".join(undocumented[:5]) + (f" and {len(undocumented) - 5} more" if len(undocumented) > 5 else "")
|
|
312
|
+
add(WARN, f"{len(undocumented)} variable(s) the code reads are missing from {example}: {shown}",
|
|
313
|
+
"setupdoctor --ci lists each one with its location")
|
|
314
|
+
|
|
315
|
+
declared = {}
|
|
316
|
+
compose = next((n for n in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
|
|
317
|
+
if (root / n).exists()), None)
|
|
318
|
+
if compose:
|
|
319
|
+
override = compose.replace(".", ".override.") # Docker merges this file in automatically
|
|
320
|
+
for host, container in COMPOSE_PORT.findall(read(root / compose) + "\n" + read(root / override)):
|
|
321
|
+
if int(container) in SERVICES:
|
|
322
|
+
declared[int(host)] = SERVICES[int(container)]
|
|
323
|
+
for port, service in sorted(declared.items()):
|
|
324
|
+
if port_open(port):
|
|
325
|
+
add(OK, f"{service} reachable on localhost:{port}", None)
|
|
326
|
+
else:
|
|
327
|
+
add(FAIL, f"{service} ({compose}) is not reachable on localhost:{port}",
|
|
328
|
+
"docker compose up -d" if on_path("docker") else "install Docker, then: docker compose up -d")
|
|
329
|
+
return checks
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Git copies this into every new clone and runs it after checkout. $1 is the previous HEAD, which is
|
|
333
|
+
# all zeros only on a fresh clone, so switching branches later stays silent.
|
|
334
|
+
HOOK = """#!/bin/sh
|
|
335
|
+
# Installed by setupdoctor. Remove with: setupdoctor --uninstall-hook
|
|
336
|
+
case "$1" in *[!0]*) exit 0 ;; esac
|
|
337
|
+
[ -x "{python}" ] && "{python}" -m setupdoctor --hook
|
|
338
|
+
exit 0
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def git_global(*args):
|
|
343
|
+
return subprocess.run(["git", "config", "--global", *args], capture_output=True, text=True).stdout.strip()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def clone_hook(install):
|
|
347
|
+
"""Add or remove the git template hook that runs setupdoctor after every `git clone`."""
|
|
348
|
+
own = Path.home() / ".setupdoctor" / "git-template"
|
|
349
|
+
template = Path(git_global("init.templateDir") or own).expanduser() # reuse a template folder you already have
|
|
350
|
+
hook = template / "hooks" / "post-checkout"
|
|
351
|
+
ours = "setupdoctor" in read(hook)
|
|
352
|
+
if not install:
|
|
353
|
+
if ours:
|
|
354
|
+
hook.unlink()
|
|
355
|
+
if template == own:
|
|
356
|
+
git_global("--unset", "init.templateDir")
|
|
357
|
+
return 0, "Removed. New clones no longer run setupdoctor." if ours else "No setupdoctor hook was installed."
|
|
358
|
+
if hook.exists() and not ours:
|
|
359
|
+
return 1, f"{hook} already exists and isn't setupdoctor's, so it was left alone."
|
|
360
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
hook.write_text(HOOK.format(python=Path(sys.executable).as_posix()), encoding="utf-8", newline="\n")
|
|
362
|
+
hook.chmod(0o755)
|
|
363
|
+
git_global("init.templateDir", template.as_posix())
|
|
364
|
+
return 0, ("Installed. Every `git clone` from now on runs setupdoctor in the new folder.\n"
|
|
365
|
+
"Repos you already cloned are not affected. Undo with: setupdoctor --uninstall-hook")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main(argv=None):
|
|
369
|
+
ap = argparse.ArgumentParser(prog="setupdoctor", description=__doc__.split(": ", 1)[1])
|
|
370
|
+
ap.add_argument("path", nargs="?", default=".", help="repo to check (default: current folder)")
|
|
371
|
+
ap.add_argument("--ci", action="store_true",
|
|
372
|
+
help="only check that every env var the code reads is in .env.example (for pipelines)")
|
|
373
|
+
ap.add_argument("--install-hook", action="store_true",
|
|
374
|
+
help="run setupdoctor automatically after every git clone (changes your global git config)")
|
|
375
|
+
ap.add_argument("--uninstall-hook", action="store_true", help="undo --install-hook")
|
|
376
|
+
ap.add_argument("--hook", action="store_true", help=argparse.SUPPRESS) # set by the git hook: quiet if nothing to check
|
|
377
|
+
args = ap.parse_args(argv)
|
|
378
|
+
if args.install_hook or args.uninstall_hook:
|
|
379
|
+
if not on_path("git"):
|
|
380
|
+
ap.error("git was not found on PATH")
|
|
381
|
+
code, message = clone_hook(install=args.install_hook)
|
|
382
|
+
print(message)
|
|
383
|
+
return code
|
|
384
|
+
if not Path(args.path).is_dir():
|
|
385
|
+
ap.error(f"not a folder: {args.path}")
|
|
386
|
+
try:
|
|
387
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
388
|
+
except (AttributeError, ValueError):
|
|
389
|
+
pass
|
|
390
|
+
color = sys.stdout.isatty() and "NO_COLOR" not in os.environ
|
|
391
|
+
if color and os.name == "nt":
|
|
392
|
+
os.system("") # switches older Windows consoles to ANSI colour mode
|
|
393
|
+
|
|
394
|
+
checks = diagnose(args.path, ci=args.ci)
|
|
395
|
+
if not checks and args.hook:
|
|
396
|
+
return 0
|
|
397
|
+
if not checks:
|
|
398
|
+
print("setupdoctor: " + ("the code reads no environment variables that need documenting" if args.ci
|
|
399
|
+
else "nothing to check here (no Node or Python requirements found)"))
|
|
400
|
+
return 0
|
|
401
|
+
print(f"setupdoctor: {len(checks)} check{'s' * (len(checks) != 1)}\n")
|
|
402
|
+
for status, message, fix in checks:
|
|
403
|
+
mark, code = MARK[status]
|
|
404
|
+
print(f" \033[{code}m{mark}\033[0m {message}" if color else f" {mark} {message}")
|
|
405
|
+
if fix:
|
|
406
|
+
print(f" fix: {fix}")
|
|
407
|
+
count = {s: sum(c[0] == s for c in checks) for s in (FAIL, WARN, OK)}
|
|
408
|
+
print(f"\n{count[FAIL]} failed, {count[WARN]} warning(s), {count[OK]} passed")
|
|
409
|
+
return 1 if count[FAIL] else 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
sys.exit(main())
|