@sitar_fiercer4c/skills 0.1.0
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.
- package/LICENSE +5 -0
- package/README.md +75 -0
- package/bin/install.js +45 -0
- package/package.json +29 -0
- package/skills/architecture-walkthrough/SKILL.md +223 -0
- package/skills/architecture-walkthrough/references/sections.md +29 -0
- package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
- package/skills/autotest-webapp-ui/SKILL.md +58 -0
- package/skills/backend-code-review/SKILL.md +386 -0
- package/skills/backend-code-review/references/report-format.md +333 -0
- package/skills/backend-code-review/scripts/list_routes.py +269 -0
- package/skills/backend-code-review/scripts/sweep.py +550 -0
- package/skills/backend-code-review/scripts/verify_citations.py +201 -0
- package/skills/be-brief/SKILL.md +18 -0
- package/skills/clarke-list-excel/SKILL.md +51 -0
- package/skills/clarke-list-excel/references/output-schema.md +125 -0
- package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
- package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
- package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
- package/skills/clarke-list-excel/scripts/run_all.py +63 -0
- package/skills/datalab-api/SKILL.md +163 -0
- package/skills/datalab-api/references/parameters-and-payload.md +121 -0
- package/skills/datalab-api/references/table-selection.md +35 -0
- package/skills/datalab-api/scripts/datalab_tables.py +365 -0
- package/skills/find-test-seam/SKILL.md +41 -0
- package/skills/frontend-code-review/SKILL.md +247 -0
- package/skills/frontend-code-review-2/SKILL.md +192 -0
- package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
- package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
- package/skills/murtaza-breif/SKILL.md +143 -0
- package/skills/murtaza-breif/scripts/save_brief.py +128 -0
- package/skills/pdf-to-json/SKILL.md +42 -0
- package/skills/pdf-to-json/references/output-schema.md +168 -0
- package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
- package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
- package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
- package/skills/record-api-traffic/SKILL.md +434 -0
- package/skills/record-api-traffic/references/reading-recordings.md +224 -0
- package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
- package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
- package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
- package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
- package/skills/record-api-traffic/scripts/preflight.py +528 -0
- package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
- package/skills/refac-wrt-business-goal/SKILL.md +305 -0
- package/skills/refac-wrt-business-goal/references/critic.md +170 -0
- package/skills/system-resource-triage/SKILL.md +180 -0
- package/skills/system-resource-triage/scripts/reap.sh +116 -0
- package/skills/system-resource-triage/scripts/triage.sh +111 -0
- package/skills/using-git-worktrees/SKILL.md +167 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only preflight for the API traffic recorder.
|
|
3
|
+
|
|
4
|
+
python3 preflight.py <repo>
|
|
5
|
+
|
|
6
|
+
Reports every blocker at once instead of dying on the first one, and says who
|
|
7
|
+
holds each contended port so the caller can judge whether stopping it is safe.
|
|
8
|
+
|
|
9
|
+
This script never kills a process, never writes a file, never edits config —
|
|
10
|
+
not in the skill, and above all not in the repo. Diagnosis only; the decision
|
|
11
|
+
to stop anything belongs to the user.
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 = clear to launch 1 = blockers found 2 = repo not found
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import socket
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
28
|
+
WORKSPACE = SKILL.parent / "record-api-traffic-workspace" / "recordings"
|
|
29
|
+
RECORDER = SKILL / "scripts" / "record-api-traffic.py"
|
|
30
|
+
SCHEMA_CHECK = SKILL / "scripts" / "check-schema.mjs"
|
|
31
|
+
DUMP_SCRIPTS = ("dump-quotation.mjs", "dump-source-excel.mjs")
|
|
32
|
+
|
|
33
|
+
SCRATCH_PREFIX = "record-api-traffic-"
|
|
34
|
+
LEGACY_SCRATCH = "Backend/.mitm-run"
|
|
35
|
+
CHILD_MARKER = "RECORD_API_TRAFFIC_RUN"
|
|
36
|
+
|
|
37
|
+
PROXY_PORT = 7100
|
|
38
|
+
BACKEND_PORT = 7101
|
|
39
|
+
OUTBOUND_PORT = 7102
|
|
40
|
+
FRONTEND_PROXY_PORT = 4100
|
|
41
|
+
FRONTEND_PORT = 4101
|
|
42
|
+
FRONTEND_OUTBOUND_PORT = 4102
|
|
43
|
+
|
|
44
|
+
PORT_ROLES = (
|
|
45
|
+
(FRONTEND_PROXY_PORT, "frontend proxy"),
|
|
46
|
+
(FRONTEND_PORT, "frontend"),
|
|
47
|
+
(FRONTEND_OUTBOUND_PORT, "frontend outbound proxy"),
|
|
48
|
+
(PROXY_PORT, "backend proxy"),
|
|
49
|
+
(BACKEND_PORT, "backend"),
|
|
50
|
+
(OUTBOUND_PORT, "backend outbound proxy"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
MITM_CA = Path.home() / ".mitmproxy" / "mitmproxy-ca-cert.pem"
|
|
54
|
+
|
|
55
|
+
OK, WARN, FAIL = "ok", "warn", "FAIL"
|
|
56
|
+
|
|
57
|
+
results = []
|
|
58
|
+
blockers = []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def add(status, check, detail, fix=None):
|
|
62
|
+
results.append((status, check, detail))
|
|
63
|
+
if status == FAIL:
|
|
64
|
+
blockers.append((check, detail, fix))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sh(argv, timeout=8):
|
|
68
|
+
try:
|
|
69
|
+
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout).stdout
|
|
70
|
+
except Exception:
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
# process / port helpers
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def cmdline(pid):
|
|
79
|
+
try:
|
|
80
|
+
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
|
81
|
+
return " ".join(raw.decode("utf-8", "replace").split("\0")).strip()
|
|
82
|
+
except OSError:
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def proc_cwd(pid):
|
|
87
|
+
try:
|
|
88
|
+
return os.readlink(f"/proc/{pid}/cwd")
|
|
89
|
+
except OSError:
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def listeners(port):
|
|
94
|
+
"""[(pid, name)] listening on port, via ss."""
|
|
95
|
+
out = sh(["ss", "-ltnpH", f"sport = :{port}"])
|
|
96
|
+
found = []
|
|
97
|
+
for match in re.finditer(r'\(\("([^"]+)",pid=(\d+)', out):
|
|
98
|
+
# Linux truncates comm to 15 chars, leaving names like "next-server (v1".
|
|
99
|
+
name = re.sub(r"\s*\(\S*$", "", match.group(1))
|
|
100
|
+
found.append((int(match.group(2)), name))
|
|
101
|
+
return found
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def proc_env_has(pid, key):
|
|
105
|
+
"""Is `key` set in this process's environment?
|
|
106
|
+
|
|
107
|
+
The frontend child has to run with the repo as its working directory, so
|
|
108
|
+
the cwd and argv tests below cannot tell a leftover dev server of ours
|
|
109
|
+
from one the user started by hand. The marker the recorder exports into
|
|
110
|
+
every child can.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
raw = Path(f"/proc/{pid}/environ").read_bytes()
|
|
114
|
+
except OSError:
|
|
115
|
+
return False
|
|
116
|
+
return any(entry.startswith(f"{key}=") for entry in
|
|
117
|
+
raw.decode("utf-8", "replace").split("\0"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def classify(pid, repo):
|
|
121
|
+
"""Is this process part of our recorder stack, the app, or something else?
|
|
122
|
+
|
|
123
|
+
Checked before the repo test, because the recorder's own children run with
|
|
124
|
+
a temp working directory and would otherwise read as foreign.
|
|
125
|
+
"""
|
|
126
|
+
argv = cmdline(pid)
|
|
127
|
+
cwd = proc_cwd(pid)
|
|
128
|
+
blob = f"{argv} {cwd}"
|
|
129
|
+
if (RECORDER.name in argv or SCRATCH_PREFIX in blob or ".mitm-run" in blob
|
|
130
|
+
or proc_env_has(pid, CHILD_MARKER)):
|
|
131
|
+
return "ours", argv
|
|
132
|
+
if str(repo) in blob:
|
|
133
|
+
return "app", argv
|
|
134
|
+
return "foreign", argv
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------
|
|
138
|
+
# checks
|
|
139
|
+
# --------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def check_skill_scripts():
|
|
142
|
+
missing = [p.name for p in
|
|
143
|
+
[RECORDER, SCHEMA_CHECK] + [SKILL / "scripts" / n for n in DUMP_SCRIPTS]
|
|
144
|
+
if not p.exists()]
|
|
145
|
+
if missing:
|
|
146
|
+
add(FAIL, "skill scripts", f"missing: {', '.join(missing)}",
|
|
147
|
+
"The skill's own scripts/ directory is incomplete.")
|
|
148
|
+
else:
|
|
149
|
+
add(OK, "skill scripts", f"recorder, schema check and {len(DUMP_SCRIPTS)} dumps present")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def check_schema(repo):
|
|
153
|
+
"""Do the dump scripts still match the app's model?
|
|
154
|
+
|
|
155
|
+
They used to live in the repo, where a schema change and the script that
|
|
156
|
+
reads it moved in the same commit. They no longer do, so nothing keeps
|
|
157
|
+
them honest but this. Drift is quiet: a renamed field reads as undefined
|
|
158
|
+
and dump-source-excel writes an empty file rather than failing.
|
|
159
|
+
"""
|
|
160
|
+
if not SCHEMA_CHECK.exists():
|
|
161
|
+
return
|
|
162
|
+
if not shutil.which("node"):
|
|
163
|
+
add(WARN, "dump scripts", "alignment unverified — node not on PATH")
|
|
164
|
+
return
|
|
165
|
+
try:
|
|
166
|
+
proc = subprocess.run(
|
|
167
|
+
["node", str(SCHEMA_CHECK), "--repo", str(repo), "--json"],
|
|
168
|
+
capture_output=True, text=True, timeout=120,
|
|
169
|
+
)
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
add(WARN, "dump scripts", "alignment check timed out after 120s")
|
|
172
|
+
return
|
|
173
|
+
if proc.returncode == 2:
|
|
174
|
+
try:
|
|
175
|
+
reason = json.loads(proc.stdout)["reason"]
|
|
176
|
+
except (ValueError, KeyError):
|
|
177
|
+
reason = (proc.stderr or proc.stdout or "").strip().split("\n")[-1]
|
|
178
|
+
add(WARN, "dump scripts", f"alignment unverified — {reason}")
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
report = json.loads(proc.stdout)
|
|
182
|
+
except ValueError:
|
|
183
|
+
add(WARN, "dump scripts", "alignment check produced no report")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
bad = [c for c in report["checks"] if c["status"] == FAIL]
|
|
187
|
+
warn = [c for c in report["checks"] if c["status"] == WARN]
|
|
188
|
+
if bad:
|
|
189
|
+
detail = "; ".join(f"{c['name']}: {c['detail']}" for c in bad)
|
|
190
|
+
add(FAIL, "dump scripts", f"out of sync with the app — {detail}",
|
|
191
|
+
"Update the dump scripts in the skill to match the model before "
|
|
192
|
+
"recording, or their artifacts will be empty or wrong.")
|
|
193
|
+
elif warn:
|
|
194
|
+
detail = "; ".join(f"{c['name']}: {c['detail']}" for c in warn)
|
|
195
|
+
add(WARN, "dump scripts", f"aligned, with caveats — {detail}")
|
|
196
|
+
else:
|
|
197
|
+
add(OK, "dump scripts", f"aligned with the app model ({len(report['checks'])} checks)")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def check_tools():
|
|
201
|
+
mitm = shutil.which("mitmdump")
|
|
202
|
+
if not mitm:
|
|
203
|
+
add(FAIL, "mitmdump", "not on PATH",
|
|
204
|
+
"Install with: pipx install mitmproxy")
|
|
205
|
+
else:
|
|
206
|
+
version = ""
|
|
207
|
+
for line in sh([mitm, "--version"]).splitlines():
|
|
208
|
+
if line.lower().startswith("mitmproxy"):
|
|
209
|
+
version = line.split()[1]
|
|
210
|
+
break
|
|
211
|
+
add(OK, "mitmdump", f"{version or 'present'} at {mitm}")
|
|
212
|
+
|
|
213
|
+
missing = [t for t in ("node", "npm") if not shutil.which(t)]
|
|
214
|
+
if missing:
|
|
215
|
+
add(FAIL, "node / npm", f"missing: {', '.join(missing)}",
|
|
216
|
+
"Install Node before recording.")
|
|
217
|
+
else:
|
|
218
|
+
node_v = sh(["node", "--version"]).strip()
|
|
219
|
+
npm_v = sh(["npm", "--version"]).strip()
|
|
220
|
+
add(OK, "node / npm", f"{node_v} / {npm_v}")
|
|
221
|
+
major = int(re.match(r"v(\d+)", node_v).group(1)) if re.match(r"v(\d+)", node_v) else 0
|
|
222
|
+
if major < 24:
|
|
223
|
+
add(WARN, "outbound proxy", f"node {node_v} ignores NODE_USE_ENV_PROXY",
|
|
224
|
+
"Outbound Graph/Datalab calls will not be recorded. Run with "
|
|
225
|
+
"--no-outbound, or upgrade Node to 24+.")
|
|
226
|
+
|
|
227
|
+
if MITM_CA.exists():
|
|
228
|
+
add(OK, "mitm CA", str(MITM_CA))
|
|
229
|
+
else:
|
|
230
|
+
add(FAIL, "mitm CA", f"{MITM_CA} missing",
|
|
231
|
+
"mitmproxy writes it on its first run. Start mitmdump once to generate "
|
|
232
|
+
"it, or launch with --no-outbound.")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def env_map(path):
|
|
236
|
+
values = {}
|
|
237
|
+
try:
|
|
238
|
+
for line in path.read_text().splitlines():
|
|
239
|
+
line = line.strip()
|
|
240
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
241
|
+
continue
|
|
242
|
+
key, _, val = line.partition("=")
|
|
243
|
+
values[key.strip()] = val.strip()
|
|
244
|
+
except OSError:
|
|
245
|
+
pass
|
|
246
|
+
return values
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def check_backend_env(repo):
|
|
250
|
+
path = repo / "Backend" / ".env"
|
|
251
|
+
if not path.exists():
|
|
252
|
+
add(FAIL, "backend env", f"missing {path}",
|
|
253
|
+
"The backend cannot start without Backend/.env.")
|
|
254
|
+
return None
|
|
255
|
+
env = env_map(path)
|
|
256
|
+
port = env.get("PORT", "(unset)")
|
|
257
|
+
notes = [f"PORT={port}"]
|
|
258
|
+
agree = []
|
|
259
|
+
for key in ("BACKEND_PUBLIC_URL", "AZURE_REDIRECT_URI"):
|
|
260
|
+
val = env.get(key, "")
|
|
261
|
+
if val and f":{PROXY_PORT}" not in val:
|
|
262
|
+
agree.append(f"{key}={val}")
|
|
263
|
+
if agree:
|
|
264
|
+
add(WARN, "backend env",
|
|
265
|
+
f"{', '.join(notes)}; not on :{PROXY_PORT}: {'; '.join(agree)}")
|
|
266
|
+
else:
|
|
267
|
+
add(OK, "backend env",
|
|
268
|
+
f"{', '.join(notes)}; public URL and OAuth redirect on :{PROXY_PORT}")
|
|
269
|
+
return env
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def check_frontend_env(repo):
|
|
273
|
+
"""The highest-value check: a frontend pointed elsewhere records nothing."""
|
|
274
|
+
path = repo / "Frontend" / ".env.local"
|
|
275
|
+
if not path.exists():
|
|
276
|
+
add(FAIL, "frontend env", f"missing {path}",
|
|
277
|
+
"Frontend/.env.local must set NEXT_PUBLIC_API_URL.")
|
|
278
|
+
return
|
|
279
|
+
url = env_map(path).get("NEXT_PUBLIC_API_URL", "")
|
|
280
|
+
if not url:
|
|
281
|
+
add(FAIL, "frontend env", "NEXT_PUBLIC_API_URL unset",
|
|
282
|
+
f"Set it to http://localhost:{PROXY_PORT}/api or the recorder is bypassed.")
|
|
283
|
+
elif f":{PROXY_PORT}" not in url:
|
|
284
|
+
add(FAIL, "frontend env", f"NEXT_PUBLIC_API_URL={url} -> bypasses the recorder",
|
|
285
|
+
f"Point it at :{PROXY_PORT}. As set, the browser talks straight to the "
|
|
286
|
+
"backend and the recording comes out empty.")
|
|
287
|
+
else:
|
|
288
|
+
add(OK, "frontend env", url)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def check_dev_script(repo):
|
|
292
|
+
"""Can the dev server be moved off :4100 so the recorder can take it?
|
|
293
|
+
|
|
294
|
+
The frontend leg needs the same trick the backend gets: the recorder binds
|
|
295
|
+
the forwarded port and the server moves one up behind it. A Next dev script
|
|
296
|
+
normally pins its own port (`next dev -H 0.0.0.0 -p 4100`), and a pinned
|
|
297
|
+
CLI flag beats $PORT -- so the recorder appends a second `-p`, which both
|
|
298
|
+
parsers Next has shipped resolve to the last occurrence. That only holds
|
|
299
|
+
for a script that actually runs `next`.
|
|
300
|
+
"""
|
|
301
|
+
path = repo / "Frontend" / "package.json"
|
|
302
|
+
try:
|
|
303
|
+
script = (json.loads(path.read_text()).get("scripts") or {}).get("dev", "")
|
|
304
|
+
except (OSError, ValueError) as exc:
|
|
305
|
+
add(FAIL, "frontend dev script", f"cannot read {path}: {exc}",
|
|
306
|
+
"The recorder starts the frontend with npm run dev.")
|
|
307
|
+
return
|
|
308
|
+
if not script:
|
|
309
|
+
add(FAIL, "frontend dev script", f"no dev script in {path}",
|
|
310
|
+
"The recorder starts the frontend with npm run dev.")
|
|
311
|
+
elif "next" in script:
|
|
312
|
+
add(OK, "frontend dev script",
|
|
313
|
+
f"{script!r} -> moved to :{FRONTEND_PORT} with -p")
|
|
314
|
+
else:
|
|
315
|
+
add(WARN, "frontend dev script",
|
|
316
|
+
f"{script!r} is not a Next dev script; the port move falls back to $PORT",
|
|
317
|
+
f"If that script pins its own port it will fight the recorder for "
|
|
318
|
+
f":{FRONTEND_PROXY_PORT}. Check it, or record with --no-frontend.")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def check_deps(repo):
|
|
322
|
+
missing = [str(p.relative_to(repo)) for p in
|
|
323
|
+
(repo / "Backend" / "node_modules", repo / "Frontend" / "node_modules")
|
|
324
|
+
if not p.exists()]
|
|
325
|
+
if missing:
|
|
326
|
+
add(FAIL, "dependencies", f"missing {', '.join(missing)}",
|
|
327
|
+
"Run npm install in the affected directory.")
|
|
328
|
+
else:
|
|
329
|
+
add(OK, "dependencies", "Backend/node_modules, Frontend/node_modules present")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def check_ports(repo):
|
|
333
|
+
for port, role in PORT_ROLES:
|
|
334
|
+
holders = listeners(port)
|
|
335
|
+
if not holders:
|
|
336
|
+
add(OK, f"port {port} ({role})", "free")
|
|
337
|
+
continue
|
|
338
|
+
described = []
|
|
339
|
+
kinds = set()
|
|
340
|
+
evidence = ""
|
|
341
|
+
for pid, name in holders:
|
|
342
|
+
kind, argv = classify(pid, repo)
|
|
343
|
+
kinds.add(kind)
|
|
344
|
+
described.append(f"{name} pid {pid} [{kind}]")
|
|
345
|
+
if kind == "foreign" and not evidence:
|
|
346
|
+
cwd = proc_cwd(pid)
|
|
347
|
+
snippet = argv if len(argv) <= 110 else argv[:107] + "..."
|
|
348
|
+
evidence = f"\n cwd {cwd}\n {snippet}"
|
|
349
|
+
add(FAIL, f"port {port} ({role})", "; ".join(described) + evidence,
|
|
350
|
+
port_fix(kinds))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def port_fix(kinds):
|
|
354
|
+
if "foreign" in kinds:
|
|
355
|
+
return ("Belongs to something outside this repo. Show the user what it is and "
|
|
356
|
+
"get explicit agreement before stopping it — it may be someone's "
|
|
357
|
+
"in-progress work.")
|
|
358
|
+
if "ours" in kinds:
|
|
359
|
+
return "Left over from an earlier recorder run; safe to offer to stop."
|
|
360
|
+
return "The app's own dev server. Offer to stop it so the recorder can take the port."
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def check_orphans(repo):
|
|
364
|
+
"""Leftovers from a previous run of THIS recorder.
|
|
365
|
+
|
|
366
|
+
Bracketed pattern so pgrep does not match the shell running the pattern.
|
|
367
|
+
"""
|
|
368
|
+
out = sh(["pgrep", "-af", r"[r]ecord-api-traffic\.py"])
|
|
369
|
+
lines = [l for l in out.splitlines() if l.strip()]
|
|
370
|
+
orphan_children = []
|
|
371
|
+
for pattern in (r"[s]erver\.js", r"[n]ext", r"[m]itmdump"):
|
|
372
|
+
for entry in sh(["pgrep", "-af", pattern]).splitlines():
|
|
373
|
+
pid = entry.split()[0] if entry.split() else ""
|
|
374
|
+
if not pid.isdigit():
|
|
375
|
+
continue
|
|
376
|
+
cwd = proc_cwd(int(pid))
|
|
377
|
+
# The marker catches the frontend, which runs in the repo; the cwd
|
|
378
|
+
# test catches children of a recorder too old to set one.
|
|
379
|
+
if (proc_env_has(int(pid), CHILD_MARKER)
|
|
380
|
+
or SCRATCH_PREFIX in cwd or ".mitm-run" in cwd):
|
|
381
|
+
orphan_children.append(entry)
|
|
382
|
+
total = lines + orphan_children
|
|
383
|
+
if total:
|
|
384
|
+
add(FAIL, "previous run", f"{len(total)} orphaned process(es) still alive",
|
|
385
|
+
"These are this recorder's own leftovers; safe to offer to stop.")
|
|
386
|
+
else:
|
|
387
|
+
add(OK, "previous run", "no orphans from this recorder")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def check_scratch(repo):
|
|
391
|
+
"""The backend's working directory is a temp dir, so nothing lands in the repo."""
|
|
392
|
+
notes = []
|
|
393
|
+
legacy = repo / LEGACY_SCRATCH
|
|
394
|
+
if legacy.exists():
|
|
395
|
+
notes.append(f"{legacy} is from an older version that wrote inside the repo; "
|
|
396
|
+
"delete it yourself — this skill will not touch the repo")
|
|
397
|
+
stale = sorted(Path(tempfile.gettempdir()).glob(f"{SCRATCH_PREFIX}*"))
|
|
398
|
+
if stale:
|
|
399
|
+
notes.append(f"{len(stale)} temp scratch dir(s) left by crashed runs, "
|
|
400
|
+
f"e.g. {stale[0]}")
|
|
401
|
+
if notes:
|
|
402
|
+
add(WARN, "scratch dir", "; ".join(notes))
|
|
403
|
+
else:
|
|
404
|
+
add(OK, "scratch dir", "clean (created under /tmp at launch, not in the repo)")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def check_workspace(repo):
|
|
408
|
+
"""Recordings live in the skill, so a run leaves the repo untouched."""
|
|
409
|
+
slug = re.sub(r"[^a-z0-9]+", "-",
|
|
410
|
+
"-".join(repo.parts[-2:]).lower()).strip("-")
|
|
411
|
+
target = WORKSPACE / slug
|
|
412
|
+
runs = sorted(target.glob("run-*")) if target.exists() else []
|
|
413
|
+
detail = f"{target}"
|
|
414
|
+
if runs:
|
|
415
|
+
detail += f" ({len(runs)} previous run(s), newest {runs[-1].name})"
|
|
416
|
+
else:
|
|
417
|
+
detail += " (no previous runs)"
|
|
418
|
+
add(OK, "recordings", detail)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def check_mongo(backend_env):
|
|
422
|
+
"""Atlas hosts are SRV-only: a plain A/AAAA lookup fails even when healthy."""
|
|
423
|
+
if not backend_env:
|
|
424
|
+
return
|
|
425
|
+
uri = backend_env.get("MONGODB_URI") or backend_env.get("MONGO_URI") or ""
|
|
426
|
+
if not uri:
|
|
427
|
+
add(WARN, "mongodb", "no MONGODB_URI in Backend/.env")
|
|
428
|
+
return
|
|
429
|
+
match = re.match(r"mongodb(\+srv)?://[^@]*@?([^/?,]+)", uri)
|
|
430
|
+
if not match:
|
|
431
|
+
add(WARN, "mongodb", "could not parse MONGODB_URI")
|
|
432
|
+
return
|
|
433
|
+
is_srv, host = bool(match.group(1)), match.group(2).split(":")[0]
|
|
434
|
+
if is_srv:
|
|
435
|
+
out = sh(["dig", "+short", "SRV", f"_mongodb._tcp.{host}"])
|
|
436
|
+
hosts = [l for l in out.splitlines() if l.strip()]
|
|
437
|
+
if hosts:
|
|
438
|
+
add(OK, "mongodb", f"SRV resolves, {len(hosts)} host(s)")
|
|
439
|
+
elif not shutil.which("dig"):
|
|
440
|
+
add(WARN, "mongodb", "dig not installed; cannot verify SRV")
|
|
441
|
+
else:
|
|
442
|
+
add(FAIL, "mongodb", f"SRV lookup failed for {host}",
|
|
443
|
+
"The backend will never pass its health check and the recorder "
|
|
444
|
+
"aborts after 90s. Check network/DNS or the Atlas cluster state.")
|
|
445
|
+
else:
|
|
446
|
+
port = 27017
|
|
447
|
+
if ":" in match.group(2):
|
|
448
|
+
port = int(match.group(2).split(":")[1])
|
|
449
|
+
with socket.socket() as s:
|
|
450
|
+
s.settimeout(3)
|
|
451
|
+
if s.connect_ex((host, port)) == 0:
|
|
452
|
+
add(OK, "mongodb", f"{host}:{port} reachable")
|
|
453
|
+
else:
|
|
454
|
+
add(FAIL, "mongodb", f"{host}:{port} refused",
|
|
455
|
+
"Start MongoDB, or the backend health check will time out.")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def check_disk(repo):
|
|
459
|
+
usage = shutil.disk_usage(repo)
|
|
460
|
+
free_gb = usage.free / 1024 ** 3
|
|
461
|
+
detail = f"{free_gb:.0f}G free on {repo}"
|
|
462
|
+
if free_gb < 1:
|
|
463
|
+
add(FAIL, "disk", detail, "Free space before recording.")
|
|
464
|
+
elif free_gb < 5:
|
|
465
|
+
add(WARN, "disk", detail + " (low)")
|
|
466
|
+
else:
|
|
467
|
+
add(OK, "disk", detail)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# --------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def main():
|
|
473
|
+
parser = argparse.ArgumentParser(description="Read-only preflight for the recorder.")
|
|
474
|
+
parser.add_argument("repo", help="path to the app repo")
|
|
475
|
+
parser.add_argument("--json", action="store_true")
|
|
476
|
+
args = parser.parse_args()
|
|
477
|
+
|
|
478
|
+
repo = Path(args.repo).expanduser().resolve()
|
|
479
|
+
if not repo.exists():
|
|
480
|
+
print(f"repo not found: {repo}", file=sys.stderr)
|
|
481
|
+
return 2
|
|
482
|
+
if not (repo / "Backend").is_dir():
|
|
483
|
+
print(f"not an app repo (no Backend/): {repo}", file=sys.stderr)
|
|
484
|
+
return 2
|
|
485
|
+
|
|
486
|
+
check_skill_scripts()
|
|
487
|
+
check_tools()
|
|
488
|
+
backend_env = check_backend_env(repo)
|
|
489
|
+
check_frontend_env(repo)
|
|
490
|
+
check_dev_script(repo)
|
|
491
|
+
check_deps(repo)
|
|
492
|
+
check_schema(repo)
|
|
493
|
+
check_ports(repo)
|
|
494
|
+
check_orphans(repo)
|
|
495
|
+
check_scratch(repo)
|
|
496
|
+
check_workspace(repo)
|
|
497
|
+
check_mongo(backend_env)
|
|
498
|
+
check_disk(repo)
|
|
499
|
+
|
|
500
|
+
if args.json:
|
|
501
|
+
print(json.dumps({
|
|
502
|
+
"repo": str(repo),
|
|
503
|
+
"blocked": bool(blockers),
|
|
504
|
+
"checks": [{"status": s, "check": c, "detail": d} for s, c, d in results],
|
|
505
|
+
"blockers": [{"check": c, "detail": d, "fix": f} for c, d, f in blockers],
|
|
506
|
+
}, indent=2))
|
|
507
|
+
return 1 if blockers else 0
|
|
508
|
+
|
|
509
|
+
width = max(len(c) for _, c, _ in results)
|
|
510
|
+
print(f"\n PREFLIGHT — API traffic recorder\n {repo}\n")
|
|
511
|
+
for status, check, detail in results:
|
|
512
|
+
print(f" {status:<4} {check:<{width}} {detail}")
|
|
513
|
+
|
|
514
|
+
if not blockers:
|
|
515
|
+
print("\n Clear to launch.\n")
|
|
516
|
+
return 0
|
|
517
|
+
|
|
518
|
+
print(f"\n {len(blockers)} blocker(s):\n")
|
|
519
|
+
for check, detail, fix in blockers:
|
|
520
|
+
print(f" - {check}: {detail}")
|
|
521
|
+
if fix:
|
|
522
|
+
print(f" {fix}")
|
|
523
|
+
print()
|
|
524
|
+
return 1
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
if __name__ == "__main__":
|
|
528
|
+
sys.exit(main())
|