@prism-d1/cli 1.7.1 → 1.9.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/dist/assets/kirocrew-app/README.md +231 -0
- package/dist/assets/kirocrew-app/app.json +94 -0
- package/dist/assets/kirocrew-app/assets/icon.png +0 -0
- package/dist/assets/kirocrew-app/scripts/digest.py +263 -0
- package/dist/assets/kirocrew-app/scripts/page_model.py +298 -0
- package/dist/assets/kirocrew-app/scripts/page_routes.py +241 -0
- package/dist/assets/kirocrew-app/scripts/prism_cli_path.py +131 -0
- package/dist/assets/kirocrew-app/scripts/prism_mcp.py +28 -0
- package/dist/assets/kirocrew-app/skills/prism-fix-findings/SKILL.md +62 -0
- package/dist/assets/kirocrew-app/ui/index.mjs +269 -0
- package/dist/src/commands/_top/mcp.d.ts +7 -0
- package/dist/src/commands/_top/mcp.d.ts.map +1 -0
- package/dist/src/commands/_top/mcp.js +23 -0
- package/dist/src/commands/_top/mcp.js.map +1 -0
- package/dist/src/commands/_top/version.d.ts +9 -0
- package/dist/src/commands/_top/version.d.ts.map +1 -1
- package/dist/src/commands/_top/version.js +22 -1
- package/dist/src/commands/_top/version.js.map +1 -1
- package/dist/src/commands/enable/kirocrew.d.ts +82 -0
- package/dist/src/commands/enable/kirocrew.d.ts.map +1 -0
- package/dist/src/commands/enable/kirocrew.js +376 -0
- package/dist/src/commands/enable/kirocrew.js.map +1 -0
- package/dist/src/dashboard/commits.d.ts +2 -0
- package/dist/src/dashboard/commits.d.ts.map +1 -1
- package/dist/src/dashboard/commits.js +4 -1
- package/dist/src/dashboard/commits.js.map +1 -1
- package/dist/src/dashboard/repos.d.ts +1 -0
- package/dist/src/dashboard/repos.d.ts.map +1 -1
- package/dist/src/dashboard/repos.js +2 -1
- package/dist/src/dashboard/repos.js.map +1 -1
- package/dist/src/dashboard/setup.d.ts +6 -1
- package/dist/src/dashboard/setup.d.ts.map +1 -1
- package/dist/src/dashboard/setup.js +17 -0
- package/dist/src/dashboard/setup.js.map +1 -1
- package/dist/src/dashboard/usage.d.ts +4 -0
- package/dist/src/dashboard/usage.d.ts.map +1 -1
- package/dist/src/dashboard/usage.js +6 -1
- package/dist/src/dashboard/usage.js.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/server.d.ts +7 -0
- package/dist/src/mcp/server.d.ts.map +1 -0
- package/dist/src/mcp/server.js +54 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/mcp/tools.d.ts +82 -0
- package/dist/src/mcp/tools.d.ts.map +1 -0
- package/dist/src/mcp/tools.js +410 -0
- package/dist/src/mcp/tools.js.map +1 -0
- package/dist/src/utils/kirocrew.d.ts +35 -0
- package/dist/src/utils/kirocrew.d.ts.map +1 -0
- package/dist/src/utils/kirocrew.js +65 -0
- package/dist/src/utils/kirocrew.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""What the PRISM page shows, built from `prism-cli dashboard --json`. Stdlib only, no I/O
|
|
2
|
+
except the small file reads named below, so it is unit-tested without Kiro Crew.
|
|
3
|
+
|
|
4
|
+
Port of the editor extension's machine mode (editor-extension/src/panel-model.ts,
|
|
5
|
+
machine.ts, text.ts). The page receives only the model; paths stay in the
|
|
6
|
+
targets list, which the page addresses by index.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
COMMIT_LOOKUP_MAX = 30
|
|
19
|
+
COMMIT_LOOKUP_CONCURRENCY = 4
|
|
20
|
+
ROW_FINDINGS_MAX = 20
|
|
21
|
+
MAX_TEXT = 2000
|
|
22
|
+
REPORT_MAX_AGE_S = 24 * 3600
|
|
23
|
+
USAGE_CACHE_SCHEMA = "prism.usage-cache.v1"
|
|
24
|
+
RESULT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$")
|
|
25
|
+
REPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
|
26
|
+
|
|
27
|
+
# C0/C1 controls (except tab and newline) and bidi controls.
|
|
28
|
+
_UNSAFE = re.compile("[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plain(s: Any, max_len: int = MAX_TEXT, one_line: bool = True) -> str:
|
|
32
|
+
t = "" if s is None else str(s)
|
|
33
|
+
t = _UNSAFE.sub("", t.replace("\r\n", "\n").replace("\r", "\n"))
|
|
34
|
+
if one_line:
|
|
35
|
+
t = re.sub(r"\s*\n\s*", " ", t)
|
|
36
|
+
return t if len(t) <= max_len else t[: max_len - 1] + "…"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def money(n: Any, currency: str) -> str:
|
|
40
|
+
v = f"{float(n or 0):.2f}"
|
|
41
|
+
return f"${v}" if currency == "USD" else f"{v} {plain(currency, 8)}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def count(n: int, word: str) -> str:
|
|
45
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _time(iso: Any) -> Optional[float]:
|
|
49
|
+
if not isinstance(iso, str) or not iso:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return _dt.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def base_name(s: Any) -> str:
|
|
58
|
+
parts = [p for p in re.split(r"[\\/]", str(s or "")) if p]
|
|
59
|
+
return plain(parts[-1] if parts else "", 120)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---- usage cache -----------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def usage_cached_today(home: Path, today: Optional[str] = None) -> bool:
|
|
65
|
+
"""True when ~/.prism/cache/usage.json holds today's usage, so `dashboard --json` will not run a cold codeburn export."""
|
|
66
|
+
try:
|
|
67
|
+
c = json.loads((home / ".prism" / "cache" / "usage.json").read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return False
|
|
70
|
+
today = today or _dt.date.today().isoformat()
|
|
71
|
+
return isinstance(c, dict) and c.get("schema") == USAGE_CACHE_SCHEMA and c.get("day") == today and isinstance(c.get("panel"), dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---- repos -----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def commit_lookup_paths(p: Dict[str, Any], max_n: int = COMMIT_LOOKUP_MAX) -> List[str]:
|
|
77
|
+
"""Repos whose last commit is looked up: codewatch results only, newest review first, capped."""
|
|
78
|
+
repos = [r for r in p.get("repos") or [] if (r.get("codewatch") or {}).get("hasResults")]
|
|
79
|
+
repos.sort(key=lambda r: _time((r.get("codewatch") or {}).get("lastRunAt")) or 0, reverse=True)
|
|
80
|
+
return [r["path"] for r in repos[:max_n] if isinstance(r.get("path"), str)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_last_commit(out: str) -> Optional[Dict[str, str]]:
|
|
84
|
+
"""`git log -1 --format=%cI%x00%h%x00%s` output, or None."""
|
|
85
|
+
parts = out.rstrip("\n").split("\0")
|
|
86
|
+
if len(parts) < 2 or _time(parts[0]) is None or not parts[1]:
|
|
87
|
+
return None
|
|
88
|
+
return {"at": parts[0], "sha": parts[1], "subject": parts[2] if len(parts) > 2 else ""}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def last_commit_args(repo: str) -> List[str]:
|
|
92
|
+
return ["git", "-C", repo, "log", "-1", "--format=%cI%x00%h%x00%s"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def machine_repos(p: Dict[str, Any], commits: Dict[str, Optional[Dict[str, str]]]) -> List[Dict[str, Any]]:
|
|
96
|
+
"""'Repositories with codewatch', newest last commit first; no commit time: last review time; neither: last."""
|
|
97
|
+
by_path = {r.get("path"): r for r in p.get("repos") or []}
|
|
98
|
+
rows = []
|
|
99
|
+
for row in (p.get("overview") or {}).get("codewatch") or []:
|
|
100
|
+
repo = by_path.get(row.get("path"))
|
|
101
|
+
found = sorted((repo or {}).get("codewatch", {}).get("findings") or [], key=lambda f: _time(f.get("started_at")) or 0, reverse=True)
|
|
102
|
+
rows.append({"row": row, "repo": repo, "commit": commits.get(row.get("path")),
|
|
103
|
+
"findings": found[:ROW_FINDINGS_MAX], "more": max(0, len(found) - ROW_FINDINGS_MAX)})
|
|
104
|
+
|
|
105
|
+
def key(m: Dict[str, Any]) -> float:
|
|
106
|
+
t = _time((m["commit"] or {}).get("at")) or _time(m["row"].get("lastRunAt"))
|
|
107
|
+
return t if t is not None else float("-inf")
|
|
108
|
+
|
|
109
|
+
rows.sort(key=key, reverse=True)
|
|
110
|
+
return rows
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def latest_review_of(repo: Optional[Dict[str, Any]]) -> Optional[Dict[str, Optional[str]]]:
|
|
114
|
+
"""The branch and commit of the repo's latest review, for the --id fallback report."""
|
|
115
|
+
cw = (repo or {}).get("codewatch") or {}
|
|
116
|
+
rid = cw.get("lastReviewId")
|
|
117
|
+
if not rid:
|
|
118
|
+
return None
|
|
119
|
+
scan = next((s for s in cw.get("scans") or [] if s.get("id") == rid), None)
|
|
120
|
+
lb = next((b for b in cw.get("latestByBranch") or [] if scan and (b.get("review") or {}).get("started_at") == scan.get("started_at")), None)
|
|
121
|
+
return {"id": rid, "branch": (scan or {}).get("branch") or (lb or {}).get("branch"), "commit": ((lb or {}).get("review") or {}).get("commit")}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def findings_badge(row: Dict[str, Any]) -> Dict[str, str]:
|
|
125
|
+
"""The repo row's badge from its latest review; the row's left border uses the same tone."""
|
|
126
|
+
if not row.get("lastRunAt"):
|
|
127
|
+
return {"text": "no review", "tone": "none"}
|
|
128
|
+
high, med, low = (int(row.get(k) or 0) for k in ("high", "medium", "low"))
|
|
129
|
+
if high:
|
|
130
|
+
return {"text": f"{high} high", "tone": "error"}
|
|
131
|
+
if med:
|
|
132
|
+
return {"text": f"{med} med", "tone": "warn"}
|
|
133
|
+
if low:
|
|
134
|
+
return {"text": f"{low} low", "tone": "none"}
|
|
135
|
+
return {"text": "clean", "tone": "ok"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def hooks_text(status: Any) -> str:
|
|
139
|
+
"""Codewatch hook status, shown as small muted text: it says the hooks run, not that the code is clean."""
|
|
140
|
+
s = plain(status or "unknown", 20)
|
|
141
|
+
return {"ok": "hooks on", "missing": "hooks off"}.get(s, "hooks " + s)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_item(i: Dict[str, Any]) -> Dict[str, str]:
|
|
145
|
+
help_text = (i.get("help") or {}).get("text")
|
|
146
|
+
return {"key": "m:" + plain(i.get("id"), 60), "label": plain(i.get("label"), 120), "status": plain(i.get("status"), 20),
|
|
147
|
+
"detail": plain(help_text or i.get("detail"), 500), "fix": plain(i.get("fix"), 500)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_model(p: Dict[str, Any], commits: Dict[str, Optional[Dict[str, str]]], version: str,
|
|
151
|
+
digest: Optional[Dict[str, Any]]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
152
|
+
"""(model for the page, targets kept by the backend). The model holds no path; times stay ISO."""
|
|
153
|
+
u = p.get("usage") or {}
|
|
154
|
+
summary = p.get("summary") or {}
|
|
155
|
+
cur = u.get("currency") or (summary.get("spend") or {}).get("currency") or "USD"
|
|
156
|
+
|
|
157
|
+
def totals(title: str, t: Optional[Dict[str, Any]], extra: str = "") -> Dict[str, str]:
|
|
158
|
+
if not t:
|
|
159
|
+
return {"title": title, "value": "no data", "detail": ""}
|
|
160
|
+
return {"title": title, "value": money(t.get("cost"), cur),
|
|
161
|
+
"detail": f"{count(int(t.get('calls') or 0), 'call')} · {count(int(t.get('sessions') or 0), 'session')}{extra}"}
|
|
162
|
+
|
|
163
|
+
if u.get("status") == "ok":
|
|
164
|
+
m = u.get("month") or None
|
|
165
|
+
cards = [totals("AI spend today", u.get("today")),
|
|
166
|
+
totals("AI spend this month", m, f" · {plain(m.get('from'), 10)} to {plain(m.get('to'), 10)}" if m else "")]
|
|
167
|
+
else:
|
|
168
|
+
cards = [{"title": "AI spend", "value": plain(u.get("status") or "unknown", 20), "detail": plain(u.get("message") or "", 300)}]
|
|
169
|
+
warn = summary.get("warnings") or {}
|
|
170
|
+
cards.append({"title": "Setup warnings", "value": str(int(warn.get("count") or 0)), "detail": plain(warn.get("first") or "nothing to fix", 300)})
|
|
171
|
+
cards.append({"title": "Sync mode", "value": plain(((p.get("machine") or {}).get("codeburnSync") or {}).get("mode") or "unknown", 20), "detail": "where codeburn usage goes"})
|
|
172
|
+
|
|
173
|
+
setup_of = {r.get("path"): r.get("status") for r in (p.get("overview") or {}).get("setup") or []}
|
|
174
|
+
rows: List[Dict[str, Any]] = []
|
|
175
|
+
targets: List[Dict[str, Any]] = []
|
|
176
|
+
for mr in machine_repos(p, commits):
|
|
177
|
+
r, c = mr["row"], mr["commit"]
|
|
178
|
+
name = plain(r.get("label") or r.get("name"), 120)
|
|
179
|
+
latest = latest_review_of(mr["repo"])
|
|
180
|
+
badge = findings_badge(r)
|
|
181
|
+
rows.append({
|
|
182
|
+
"name": name,
|
|
183
|
+
"review": f"{int(r.get('high') or 0)} high · {int(r.get('medium') or 0)} med · {int(r.get('low') or 0)} low" if r.get("lastRunAt") else "no review yet",
|
|
184
|
+
# Raw ISO times: the page shows them relative and re-renders them on its own clock.
|
|
185
|
+
"reviewAt": plain(r.get("lastRunAt") or "", 40),
|
|
186
|
+
"badge": badge,
|
|
187
|
+
"tone": badge["tone"],
|
|
188
|
+
"hooks": hooks_text(setup_of.get(r.get("path"))),
|
|
189
|
+
"commit": {"sha": plain(c["sha"], 12), "at": plain(c["at"], 40), "subject": plain(c["subject"], 80)} if c else None,
|
|
190
|
+
"findings": [{"severity": plain(f.get("severity"), 10),
|
|
191
|
+
"location": plain(f.get("file"), 200) + (f":{f['line']}" if isinstance(f.get("line"), int) and not isinstance(f.get("line"), bool) else ""),
|
|
192
|
+
"message": plain(f.get("message"), 300), "branch": plain(f.get("branch") or "(detached)", 80)} for f in mr["findings"]],
|
|
193
|
+
"more": f"{count(mr['more'], 'more finding')} not listed; open the report for all of them." if mr["more"] else "",
|
|
194
|
+
"hasReport": bool(latest),
|
|
195
|
+
})
|
|
196
|
+
targets.append({"path": r.get("path"), "name": name, "commit": c, "latest": latest})
|
|
197
|
+
|
|
198
|
+
counts = (p.get("overview") or {}).get("counts") or {}
|
|
199
|
+
model = {
|
|
200
|
+
"generatedAt": plain(p.get("generated_at"), 40),
|
|
201
|
+
"version": plain(version or "unknown", 40),
|
|
202
|
+
"usageUpdatedAt": plain(u.get("updatedAt") or "", 40),
|
|
203
|
+
"usageStale": bool(u.get("stale")),
|
|
204
|
+
"cards": cards,
|
|
205
|
+
"topModels": [{"name": plain(x.get("name"), 120), "value": f"{money(x.get('cost'), cur)} · {count(int(x.get('calls') or 0), 'call')}"} for x in u.get("topModels") or []],
|
|
206
|
+
"topProjects": [{"name": base_name(x.get("name")), "value": f"{money(x.get('cost'), cur)} · {count(int(x.get('sessions') or 0), 'session')}"} for x in u.get("topProjects") or []],
|
|
207
|
+
"checklist": [_check_item(i) for i in (p.get("machine") or {}).get("setup") or []],
|
|
208
|
+
"repos": rows,
|
|
209
|
+
"reposNote": f"{counts.get('codewatch', 0)} of {counts.get('repos', 0)} repos found under your home folder have codewatch. Newest commit first." if counts else "",
|
|
210
|
+
"digest": digest_view(digest),
|
|
211
|
+
}
|
|
212
|
+
return model, targets
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---- index resolution and report -------------------------------------------
|
|
216
|
+
|
|
217
|
+
def parse_index(v: Any) -> Optional[int]:
|
|
218
|
+
"""A non-negative integer from a query value, else None. No sign, no spaces, at most 6 digits."""
|
|
219
|
+
if isinstance(v, str) and re.fullmatch(r"\d{1,6}", v):
|
|
220
|
+
return int(v)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def target_for(targets: List[Dict[str, Any]], v: Any) -> Optional[Dict[str, Any]]:
|
|
225
|
+
"""The backend's own target for a page-supplied index, or None when it is not an index into the last result."""
|
|
226
|
+
i = parse_index(v)
|
|
227
|
+
return targets[i] if i is not None and i < len(targets) else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def report_plan(target: Dict[str, Any], output: str) -> Dict[str, Any]:
|
|
231
|
+
"""Primary: the review covering HEAD. Fallback: the latest review by id, with a note saying which branch and commit it covers."""
|
|
232
|
+
repo = target["path"]
|
|
233
|
+
primary = ["codewatch", "report", "--repo", repo, "--commit", "HEAD", "--format", "html", "--output", output]
|
|
234
|
+
c = target.get("commit")
|
|
235
|
+
head = f"commit {plain(c['sha'], 12)}" if c else "its last commit"
|
|
236
|
+
plan: Dict[str, Any] = {"primary": primary, "primaryNote": f"Review covering {plain(target['name'], 120)}'s last commit ({head}).", "fallback": None, "fallbackNote": ""}
|
|
237
|
+
latest = target.get("latest")
|
|
238
|
+
if latest and isinstance(latest.get("id"), str) and RESULT_ID.match(latest["id"]):
|
|
239
|
+
plan["fallback"] = ["codewatch", "report", "--repo", repo, "--id", latest["id"], "--format", "html", "--output", output]
|
|
240
|
+
commit = plain(latest.get("commit"), 40)[:12] if latest.get("commit") else "(unknown)"
|
|
241
|
+
plan["fallbackNote"] = (f"No review in {plain(target['name'], 120)} covers {head}. Showing the latest review instead: "
|
|
242
|
+
f"branch {plain(latest.get('branch') or '(unknown)', 80)}, commit {commit}.")
|
|
243
|
+
return plan
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_META_REFRESH = re.compile(r"<meta\b[^>]*http-equiv\s*=\s*[\"']?\s*refresh[^>]*>", re.I)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def with_report_csp(html: str) -> str:
|
|
250
|
+
"""The report with a CSP meta tag (its inline styles only, no script, nothing remote) and no
|
|
251
|
+
meta refresh, which a CSP does not stop from navigating the frame."""
|
|
252
|
+
html = _META_REFRESH.sub("", html)
|
|
253
|
+
meta = f'<meta http-equiv="Content-Security-Policy" content="{REPORT_CSP}">'
|
|
254
|
+
m = re.search(r"<head(?:\s[^>]*)?>", html, re.I) # not <header>
|
|
255
|
+
if m:
|
|
256
|
+
return html[: m.end()] + meta + html[m.end():]
|
|
257
|
+
return f"<!DOCTYPE html><html><head>{meta}</head><body>{html}</body></html>"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cleanup_reports(report_dir: Path, now: Optional[float] = None, max_age: int = REPORT_MAX_AGE_S) -> int:
|
|
261
|
+
"""Delete this app's report files older than max_age. Only *.html directly in report_dir; links are left alone."""
|
|
262
|
+
now = time.time() if now is None else now
|
|
263
|
+
removed = 0
|
|
264
|
+
try:
|
|
265
|
+
entries = list(os.scandir(report_dir))
|
|
266
|
+
except OSError:
|
|
267
|
+
return 0
|
|
268
|
+
for e in entries:
|
|
269
|
+
try:
|
|
270
|
+
if e.name.endswith(".html") and e.is_file(follow_symlinks=False) and now - e.stat(follow_symlinks=False).st_mtime > max_age:
|
|
271
|
+
os.unlink(e.path)
|
|
272
|
+
removed += 1
|
|
273
|
+
except OSError:
|
|
274
|
+
continue
|
|
275
|
+
return removed
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---- digest ----------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def read_digest(data_dir: Path) -> Optional[Dict[str, Any]]:
|
|
281
|
+
"""The digest cron's saved state (digest-state.json), or None when missing or unreadable."""
|
|
282
|
+
try:
|
|
283
|
+
d = json.loads((data_dir / "digest-state.json").read_text(encoding="utf-8"))
|
|
284
|
+
except (OSError, ValueError):
|
|
285
|
+
return None
|
|
286
|
+
return d if isinstance(d, dict) else None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def digest_view(d: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
290
|
+
"""Raw digest fields; the page turns them into 'Not run yet' / 'Last run 3 h ago · ...'."""
|
|
291
|
+
if not d:
|
|
292
|
+
return {"recorded": False, "lastRunAt": "", "lastNotifiedAt": "", "lastMessage": ""}
|
|
293
|
+
return {
|
|
294
|
+
"recorded": True,
|
|
295
|
+
"lastRunAt": plain(d.get("lastRunAt") or "", 40),
|
|
296
|
+
"lastNotifiedAt": plain(d.get("lastNotifiedAt") or "", 40),
|
|
297
|
+
"lastMessage": plain(d.get("lastMessage") or "", 1500, one_line=False),
|
|
298
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The PRISM page's backend: in-gateway routes (app.json backend.hooks.routes).
|
|
2
|
+
|
|
3
|
+
They are served by the gateway at /api/apps/prism/<path>, behind its token
|
|
4
|
+
middleware (a dashboard session or an app token scoped to this app's
|
|
5
|
+
permissions.api). Each handler also requires the dashboard owner, or this app's
|
|
6
|
+
own token. No port, no process of our own. Everything is read-only: the routes
|
|
7
|
+
run `prism-cli dashboard --json`, `prism-cli --version`, `git log -1` and
|
|
8
|
+
`prism-cli codewatch report` (argv lists, no shell, timeouts).
|
|
9
|
+
|
|
10
|
+
The page sends only indexes; paths come from this module's own last result.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
20
|
+
|
|
21
|
+
from aiohttp import web
|
|
22
|
+
|
|
23
|
+
from . import page_model as pm
|
|
24
|
+
from .prism_cli_path import cli_env, load_config, resolve_cli
|
|
25
|
+
|
|
26
|
+
DASHBOARD_TIMEOUT_S = 110
|
|
27
|
+
REAP_TIMEOUT_S = 180
|
|
28
|
+
VERSION_TIMEOUT_S = 10
|
|
29
|
+
GIT_TIMEOUT_S = 10
|
|
30
|
+
REPORT_TIMEOUT_S = 60
|
|
31
|
+
MAX_JSON_BYTES = 64 * 1024 * 1024
|
|
32
|
+
MAX_REPORT_BYTES = 5 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
_state: Dict[str, Any] = {"targets": [], "gen": None}
|
|
35
|
+
_lock = asyncio.Lock()
|
|
36
|
+
_reaping: Set["asyncio.Task[None]"] = set()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Route:
|
|
40
|
+
def __init__(self, method: str, path: str, handler: Any) -> None:
|
|
41
|
+
self.method, self.path, self.handler = method, path, handler
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register_routes(ctx: Any) -> List[_Route]:
|
|
45
|
+
return [_Route("GET", "/data", handle_data), _Route("GET", "/report", handle_report)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _err(status: int, msg: str) -> web.Response:
|
|
49
|
+
return web.json_response({"error": msg}, status=status)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _gate(request: web.Request, ctx: Any, op: str) -> Optional[web.Response]:
|
|
53
|
+
"""None when the caller is the dashboard owner or this app's own token; else the refusal."""
|
|
54
|
+
app = request.get("app")
|
|
55
|
+
if app:
|
|
56
|
+
# The gateway's token middleware set this after verifying the app token.
|
|
57
|
+
return None if app == ctx.name else _err(403, "not this app's token")
|
|
58
|
+
gate = getattr(ctx, "require_owner", None)
|
|
59
|
+
if gate is None:
|
|
60
|
+
return web.json_response({"error": "owner authorization required", "code": "owner_only"}, status=403) # fail closed
|
|
61
|
+
return await gate(request, op)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _data_dir(ctx: Any) -> Path:
|
|
65
|
+
return Path(ctx.data_dir)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cli(ctx: Any) -> Tuple[Optional[str], str]:
|
|
69
|
+
setting = load_config(_data_dir(ctx)).get("cliPath")
|
|
70
|
+
return resolve_cli(setting if isinstance(setting, str) else None, os.environ)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _run(argv: List[str], cwd: str, env: Dict[str, str], timeout: float) -> Tuple[int, bytes]:
|
|
74
|
+
proc = await asyncio.create_subprocess_exec(*argv, cwd=cwd, env=env, stdin=asyncio.subprocess.DEVNULL,
|
|
75
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
|
76
|
+
try:
|
|
77
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout)
|
|
78
|
+
except asyncio.TimeoutError:
|
|
79
|
+
proc.kill()
|
|
80
|
+
await proc.wait()
|
|
81
|
+
raise
|
|
82
|
+
return proc.returncode or 0, out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _reap(proc: "asyncio.subprocess.Process") -> None:
|
|
86
|
+
async def drain() -> None:
|
|
87
|
+
assert proc.stdout is not None
|
|
88
|
+
while await proc.stdout.read(65536):
|
|
89
|
+
pass
|
|
90
|
+
await proc.wait()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
await asyncio.wait_for(drain(), REAP_TIMEOUT_S) # one deadline for the whole tail
|
|
94
|
+
except Exception: # timeout or a broken pipe: make sure the child does not outlive us
|
|
95
|
+
if proc.returncode is None:
|
|
96
|
+
proc.kill()
|
|
97
|
+
await proc.wait()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _dashboard(cli: str, env: Dict[str, str]) -> Dict[str, Any]:
|
|
101
|
+
"""`dashboard --json`, returned as soon as the document is printed.
|
|
102
|
+
|
|
103
|
+
With a stale usage cache the CLI prints the cached data, then waits for
|
|
104
|
+
its background codeburn refresh before exiting; that tail is left to
|
|
105
|
+
finish in the background (and killed after REAP_TIMEOUT_S).
|
|
106
|
+
"""
|
|
107
|
+
proc = await asyncio.create_subprocess_exec(cli, "dashboard", "--json", cwd=str(Path.home()), env=env,
|
|
108
|
+
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE,
|
|
109
|
+
stderr=asyncio.subprocess.DEVNULL)
|
|
110
|
+
assert proc.stdout is not None
|
|
111
|
+
chunks: List[bytes] = []
|
|
112
|
+
|
|
113
|
+
async def read_doc() -> None:
|
|
114
|
+
size = 0
|
|
115
|
+
while True:
|
|
116
|
+
line = await proc.stdout.readline()
|
|
117
|
+
if not line:
|
|
118
|
+
return
|
|
119
|
+
size += len(line)
|
|
120
|
+
if size > MAX_JSON_BYTES:
|
|
121
|
+
raise ValueError("prism-cli dashboard --json output is too large")
|
|
122
|
+
chunks.append(line)
|
|
123
|
+
if line == b"}\n": # the document is pretty-printed; this closes it
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
await asyncio.wait_for(read_doc(), DASHBOARD_TIMEOUT_S)
|
|
128
|
+
except BaseException:
|
|
129
|
+
proc.kill()
|
|
130
|
+
await proc.wait()
|
|
131
|
+
raise
|
|
132
|
+
task = asyncio.ensure_future(_reap(proc))
|
|
133
|
+
_reaping.add(task)
|
|
134
|
+
task.add_done_callback(_reaping.discard)
|
|
135
|
+
payload = json.loads(b"".join(chunks).decode("utf-8"))
|
|
136
|
+
if not isinstance(payload, dict) or not str(payload.get("schema", "")).startswith("prism.dashboard."):
|
|
137
|
+
raise ValueError("prism-cli dashboard --json returned an unexpected document")
|
|
138
|
+
return payload
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _version(cli: str, env: Dict[str, str]) -> str:
|
|
142
|
+
try:
|
|
143
|
+
rc, out = await _run([cli, "--version"], str(Path.home()), env, VERSION_TIMEOUT_S)
|
|
144
|
+
except (OSError, asyncio.TimeoutError):
|
|
145
|
+
return "unknown"
|
|
146
|
+
return out.decode("utf-8", "replace").strip().splitlines()[0] if rc == 0 and out.strip() else "unknown"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _commits(paths: List[str]) -> Dict[str, Optional[Dict[str, str]]]:
|
|
150
|
+
sem = asyncio.Semaphore(pm.COMMIT_LOOKUP_CONCURRENCY)
|
|
151
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
|
152
|
+
|
|
153
|
+
async def one(path: str) -> Optional[Dict[str, str]]:
|
|
154
|
+
async with sem:
|
|
155
|
+
try:
|
|
156
|
+
rc, out = await _run(pm.last_commit_args(path), path, env, GIT_TIMEOUT_S)
|
|
157
|
+
except (OSError, asyncio.TimeoutError):
|
|
158
|
+
return None
|
|
159
|
+
return pm.parse_last_commit(out.decode("utf-8", "replace")) if rc == 0 else None
|
|
160
|
+
|
|
161
|
+
found = await asyncio.gather(*(one(p) for p in paths))
|
|
162
|
+
return dict(zip(paths, found))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def handle_data(request: web.Request, ctx: Any) -> web.Response:
|
|
166
|
+
denied = await _gate(request, ctx, "data")
|
|
167
|
+
if denied is not None:
|
|
168
|
+
return denied
|
|
169
|
+
cli, reason = _cli(ctx)
|
|
170
|
+
if not cli:
|
|
171
|
+
return _err(503, reason)
|
|
172
|
+
# Never start a cold codeburn export on our own: only the page's explicit button sets allowExport.
|
|
173
|
+
if request.query.get("allowExport") != "1" and not pm.usage_cached_today(Path.home()):
|
|
174
|
+
return web.json_response({"needsUsageCache": True})
|
|
175
|
+
env = cli_env(cli, os.environ)
|
|
176
|
+
async with _lock:
|
|
177
|
+
try:
|
|
178
|
+
payload = await _dashboard(cli, env)
|
|
179
|
+
except (OSError, ValueError, asyncio.TimeoutError) as e:
|
|
180
|
+
return _err(502, "prism-cli dashboard --json failed: " + pm.plain(e, 200))
|
|
181
|
+
version, commits = await asyncio.gather(_version(cli, env), _commits(pm.commit_lookup_paths(payload)))
|
|
182
|
+
try:
|
|
183
|
+
model, targets = pm.build_model(payload, commits, version, pm.read_digest(_data_dir(ctx)))
|
|
184
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
185
|
+
return _err(502, "prism-cli dashboard --json returned data this page cannot read: " + pm.plain(e, 200))
|
|
186
|
+
# The page sends this back with each index, so an index always means the list it was drawn from.
|
|
187
|
+
_state["gen"] = model["gen"] = uuid.uuid4().hex
|
|
188
|
+
_state["targets"] = targets
|
|
189
|
+
return web.json_response(model)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def handle_report(request: web.Request, ctx: Any) -> web.Response:
|
|
193
|
+
denied = await _gate(request, ctx, "report")
|
|
194
|
+
if denied is not None:
|
|
195
|
+
return denied
|
|
196
|
+
if request.query.get("gen") != _state.get("gen"):
|
|
197
|
+
return _err(409, "the data changed since this page loaded; refresh and try again")
|
|
198
|
+
target = pm.target_for(_state["targets"], request.query.get("repoIndex"))
|
|
199
|
+
if target is None:
|
|
200
|
+
return _err(400, "unknown repo; refresh the page")
|
|
201
|
+
cli, reason = _cli(ctx)
|
|
202
|
+
if not cli:
|
|
203
|
+
return _err(503, reason)
|
|
204
|
+
report_dir = _data_dir(ctx) / "reports"
|
|
205
|
+
try:
|
|
206
|
+
report_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
207
|
+
except OSError as e:
|
|
208
|
+
return _err(502, "could not create the report folder: " + pm.plain(e, 200))
|
|
209
|
+
pm.cleanup_reports(report_dir)
|
|
210
|
+
out = report_dir / f"report-{uuid.uuid4().hex}.html"
|
|
211
|
+
plan = pm.report_plan(target, str(out))
|
|
212
|
+
env = cli_env(cli, os.environ)
|
|
213
|
+
attempts = [(plan["primary"], plan["primaryNote"])] + ([(plan["fallback"], plan["fallbackNote"])] if plan["fallback"] else [])
|
|
214
|
+
causes: List[str] = []
|
|
215
|
+
for args, note in attempts:
|
|
216
|
+
try:
|
|
217
|
+
rc, _ = await _run([cli, *args], target["path"], env, REPORT_TIMEOUT_S)
|
|
218
|
+
except asyncio.TimeoutError:
|
|
219
|
+
causes.append(f"timed out after {REPORT_TIMEOUT_S} s")
|
|
220
|
+
continue
|
|
221
|
+
except OSError as e:
|
|
222
|
+
causes.append(pm.plain(e, 200))
|
|
223
|
+
continue
|
|
224
|
+
if rc != 0 or not out.is_file():
|
|
225
|
+
causes.append(f"exit {rc}")
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
if out.stat().st_size > MAX_REPORT_BYTES:
|
|
229
|
+
return _err(502, "the report is too large to show here; run prism-cli codewatch report in a terminal")
|
|
230
|
+
html = out.read_text(encoding="utf-8", errors="replace")
|
|
231
|
+
except OSError as e:
|
|
232
|
+
return _err(502, "could not read the report: " + pm.plain(e, 200))
|
|
233
|
+
finally:
|
|
234
|
+
# Read once, then gone; cleanup_reports catches files a crash left behind.
|
|
235
|
+
try:
|
|
236
|
+
out.unlink()
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
return web.json_response({"repo": target["name"], "note": note, "html": pm.with_report_csp(html)})
|
|
240
|
+
ctx.logger.warning("prism report for %s failed: %s", target["name"], "; ".join(causes))
|
|
241
|
+
return _err(502, f"prism-cli codewatch report found no review for {target['name']} ({'; '.join(causes)})")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Find prism-cli the way the PRISM editor extension does (editor-extension/src/cli.ts).
|
|
2
|
+
|
|
3
|
+
The gateway and kiro-cli often start without nvm's bin folder on PATH (a
|
|
4
|
+
service unit or a desktop launcher does not read .bashrc), so a plain PATH
|
|
5
|
+
lookup misses an nvm or Volta install. No shell is run to find it.
|
|
6
|
+
|
|
7
|
+
Order: the `cliPath` setting (absolute, executable), PATH, $NVM_BIN,
|
|
8
|
+
~/.nvm/versions/node/*/bin (alias default first, then newest), Volta,
|
|
9
|
+
~/.local/bin, ~/.npm-global/bin, $NPM_CONFIG_PREFIX/bin.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, Dict, List, Mapping, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
CLI_NAME = "prism-cli"
|
|
20
|
+
MAX_ALIAS_HOPS = 4 # default -> lts/* -> lts/<name> -> version
|
|
21
|
+
_VERSION_DIR = re.compile(r"^v\d+\.\d+\.\d+$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def app_data_dir() -> Path:
|
|
25
|
+
"""The app's data folder: <app dir>/data, or $PRISM_APP_DATA (for tests and smoke runs)."""
|
|
26
|
+
override = os.environ.get("PRISM_APP_DATA", "")
|
|
27
|
+
if override:
|
|
28
|
+
return Path(override)
|
|
29
|
+
# Installed layout: <data home>/apps/<app>/scripts/this_file.py -> <data home>/apps/<app>/data
|
|
30
|
+
return Path(__file__).absolute().parent.parent / "data"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config(data_dir: Path) -> dict:
|
|
34
|
+
"""data/config.json as a dict; {} when missing or unreadable."""
|
|
35
|
+
try:
|
|
36
|
+
cfg = json.loads((data_dir / "config.json").read_text(encoding="utf-8"))
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
return {}
|
|
39
|
+
return cfg if isinstance(cfg, dict) else {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_executable_file(p: str) -> bool:
|
|
43
|
+
return os.path.isfile(p) and os.access(p, os.X_OK)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _version_key(v: str) -> Tuple[int, ...]:
|
|
47
|
+
return tuple(int(x) for x in v[1:].split("."))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_text(p: Path) -> Optional[str]:
|
|
51
|
+
try:
|
|
52
|
+
return p.read_text(encoding="utf-8").strip()
|
|
53
|
+
except OSError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _nvm_default(home: Path, versions: List[str]) -> Optional[str]:
|
|
58
|
+
"""The installed version ~/.nvm/alias/default names (following a few alias hops), or None."""
|
|
59
|
+
alias = _read_text(home / ".nvm" / "alias" / "default")
|
|
60
|
+
for _ in range(MAX_ALIAS_HOPS):
|
|
61
|
+
if not alias:
|
|
62
|
+
return None
|
|
63
|
+
if alias in ("node", "stable"):
|
|
64
|
+
return versions[0] if versions else None
|
|
65
|
+
m = re.match(r"^v?(\d+(?:\.\d+){0,2})$", alias)
|
|
66
|
+
if m:
|
|
67
|
+
want = "v" + m.group(1)
|
|
68
|
+
return next((v for v in versions if v == want or v.startswith(want + ".")), None)
|
|
69
|
+
if os.path.isabs(alias) or ".." in alias.split("/"):
|
|
70
|
+
return None
|
|
71
|
+
alias = _read_text(home / ".nvm" / "alias" / alias)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def nvm_bin_dirs(home: Path) -> List[str]:
|
|
76
|
+
root = home / ".nvm" / "versions" / "node"
|
|
77
|
+
try:
|
|
78
|
+
versions = sorted((v for v in os.listdir(root) if _VERSION_DIR.match(v)), key=_version_key, reverse=True)
|
|
79
|
+
except OSError:
|
|
80
|
+
return []
|
|
81
|
+
default = _nvm_default(home, versions)
|
|
82
|
+
if default:
|
|
83
|
+
versions = [default] + [v for v in versions if v != default]
|
|
84
|
+
return [str(root / v / "bin") for v in versions]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def user_install_dirs(home: Path, env: Mapping[str, str]) -> List[str]:
|
|
88
|
+
dirs = [
|
|
89
|
+
env.get("NVM_BIN"),
|
|
90
|
+
*nvm_bin_dirs(home),
|
|
91
|
+
os.path.join(env["VOLTA_HOME"], "bin") if env.get("VOLTA_HOME") else str(home / ".volta" / "bin"),
|
|
92
|
+
str(home / ".local" / "bin"),
|
|
93
|
+
str(home / ".npm-global" / "bin"),
|
|
94
|
+
os.path.join(env["NPM_CONFIG_PREFIX"], "bin") if env.get("NPM_CONFIG_PREFIX") else None,
|
|
95
|
+
]
|
|
96
|
+
out: List[str] = []
|
|
97
|
+
for d in dirs:
|
|
98
|
+
if d and os.path.isabs(d) and d not in out:
|
|
99
|
+
out.append(d)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_cli(
|
|
104
|
+
setting: Optional[str],
|
|
105
|
+
env: Mapping[str, str],
|
|
106
|
+
home: Optional[Path] = None,
|
|
107
|
+
is_exec: Callable[[str], bool] = is_executable_file,
|
|
108
|
+
) -> Tuple[Optional[str], str]:
|
|
109
|
+
"""(path, "") when found, else (None, reason)."""
|
|
110
|
+
if setting:
|
|
111
|
+
if not os.path.isabs(setting):
|
|
112
|
+
return None, "cliPath must be an absolute path"
|
|
113
|
+
return (setting, "") if is_exec(setting) else (None, "cliPath is not an executable file")
|
|
114
|
+
for d in env.get("PATH", "").split(os.pathsep):
|
|
115
|
+
# A relative PATH entry would resolve against whatever the working directory is.
|
|
116
|
+
if d and os.path.isabs(d) and is_exec(os.path.join(d, CLI_NAME)):
|
|
117
|
+
return os.path.join(d, CLI_NAME), ""
|
|
118
|
+
home = home or Path.home()
|
|
119
|
+
for d in user_install_dirs(home, env):
|
|
120
|
+
p = os.path.join(d, CLI_NAME)
|
|
121
|
+
if is_exec(p):
|
|
122
|
+
return p, ""
|
|
123
|
+
return None, f"{CLI_NAME} is not on PATH or in the usual install folders (nvm, Volta, ~/.local/bin, npm prefix); set cliPath in the app's data/config.json"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cli_env(cli_path: str, env: Mapping[str, str]) -> Dict[str, str]:
|
|
127
|
+
"""env with prism-cli's folder first on PATH, so its `#!/usr/bin/env node` finds the node next to it."""
|
|
128
|
+
out = dict(env)
|
|
129
|
+
d = os.path.dirname(cli_path)
|
|
130
|
+
out["PATH"] = d + os.pathsep + env["PATH"] if env.get("PATH") else d
|
|
131
|
+
return out
|