@prism-d1/cli 1.8.0 → 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.
Files changed (29) hide show
  1. package/dist/assets/kirocrew-app/README.md +231 -0
  2. package/dist/assets/kirocrew-app/app.json +94 -0
  3. package/dist/assets/kirocrew-app/assets/icon.png +0 -0
  4. package/dist/assets/kirocrew-app/scripts/digest.py +263 -0
  5. package/dist/assets/kirocrew-app/scripts/page_model.py +298 -0
  6. package/dist/assets/kirocrew-app/scripts/page_routes.py +241 -0
  7. package/dist/assets/kirocrew-app/scripts/prism_cli_path.py +131 -0
  8. package/dist/assets/kirocrew-app/scripts/prism_mcp.py +28 -0
  9. package/dist/assets/kirocrew-app/skills/prism-fix-findings/SKILL.md +62 -0
  10. package/dist/assets/kirocrew-app/ui/index.mjs +269 -0
  11. package/dist/src/commands/_top/version.d.ts +9 -0
  12. package/dist/src/commands/_top/version.d.ts.map +1 -1
  13. package/dist/src/commands/_top/version.js +22 -1
  14. package/dist/src/commands/_top/version.js.map +1 -1
  15. package/dist/src/commands/enable/kirocrew.d.ts +82 -0
  16. package/dist/src/commands/enable/kirocrew.d.ts.map +1 -0
  17. package/dist/src/commands/enable/kirocrew.js +376 -0
  18. package/dist/src/commands/enable/kirocrew.js.map +1 -0
  19. package/dist/src/dashboard/setup.d.ts +6 -1
  20. package/dist/src/dashboard/setup.d.ts.map +1 -1
  21. package/dist/src/dashboard/setup.js +17 -0
  22. package/dist/src/dashboard/setup.js.map +1 -1
  23. package/dist/src/index.js +1 -1
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/utils/kirocrew.d.ts +35 -0
  26. package/dist/src/utils/kirocrew.d.ts.map +1 -0
  27. package/dist/src/utils/kirocrew.js +65 -0
  28. package/dist/src/utils/kirocrew.js.map +1 -0
  29. package/package.json +2 -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