@grifhinz/logics-manager 2.3.3 → 2.5.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/README.md +8 -1
- package/VERSION +1 -1
- package/clients/shared-web/media/css/toolbar.css +41 -3
- package/clients/shared-web/media/renderMarkdown.js +1 -1
- package/clients/shared-web/media/webviewChrome.js +31 -8
- package/clients/shared-web/media/webviewSelectors.js +8 -7
- package/clients/viewer/browser-host.js +1329 -48
- package/clients/viewer/index.html +30 -28
- package/clients/viewer/viewer.css +686 -0
- package/logics_manager/audit.py +2 -2
- package/logics_manager/insights.py +1 -1
- package/logics_manager/lint.py +2 -2
- package/logics_manager/viewer.py +377 -11
- package/package.json +4 -2
- package/pyproject.toml +1 -1
package/logics_manager/lint.py
CHANGED
|
@@ -25,8 +25,8 @@ KINDS = {
|
|
|
25
25
|
"request": Kind("logics/request", "req", False, ("From version", "Understanding", "Confidence"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
|
|
26
26
|
"backlog": Kind("logics/backlog", "item", True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
|
|
27
27
|
"task": Kind("logics/tasks", "task", True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
|
|
28
|
-
"product": Kind("logics/product", "prod", False, ("Date", "Status", "Related request", "Related backlog", "Related task", "Related architecture", "Reminder"), ("Draft", "Proposed", "Active", "Validated", "Rejected", "Superseded", "Archived")),
|
|
29
|
-
"architecture": Kind("logics/architecture", "adr", False, ("Date", "Status", "Drivers", "Related request", "Related backlog", "Related task", "Reminder"), ("Draft", "Proposed", "Accepted", "Rejected", "Superseded", "Archived")),
|
|
28
|
+
"product": Kind("logics/product", "prod", False, ("Date", "Status", "Related request", "Related backlog", "Related task", "Related architecture", "Reminder"), ("Draft", "Proposed", "Active", "Accepted", "Validated", "Rejected", "Superseded", "Settled", "Archived")),
|
|
29
|
+
"architecture": Kind("logics/architecture", "adr", False, ("Date", "Status", "Drivers", "Related request", "Related backlog", "Related task", "Reminder"), ("Draft", "Proposed", "Accepted", "Validated", "Rejected", "Superseded", "Settled", "Archived")),
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
WORKFLOW_KINDS = {"request", "backlog", "task"}
|
package/logics_manager/viewer.py
CHANGED
|
@@ -5,6 +5,8 @@ import json
|
|
|
5
5
|
import mimetypes
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
8
10
|
import subprocess
|
|
9
11
|
import sys
|
|
10
12
|
import webbrowser
|
|
@@ -328,9 +330,16 @@ def collect_viewer_items(repo_root: Path) -> list[dict[str, Any]]:
|
|
|
328
330
|
return items
|
|
329
331
|
|
|
330
332
|
|
|
331
|
-
def viewer_data_payload(
|
|
333
|
+
def viewer_data_payload(
|
|
334
|
+
repo_root: Path,
|
|
335
|
+
selected_id: str | None = None,
|
|
336
|
+
*,
|
|
337
|
+
auto_refresh_interval_seconds: int = 15,
|
|
338
|
+
) -> dict[str, Any]:
|
|
332
339
|
return {
|
|
333
340
|
"root": str(repo_root.resolve()),
|
|
341
|
+
"repoName": repo_root.resolve().name,
|
|
342
|
+
"autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
|
|
334
343
|
"items": collect_viewer_items(repo_root),
|
|
335
344
|
"updateInfo": get_update_info(_current_version()).to_payload(),
|
|
336
345
|
"selectedId": selected_id,
|
|
@@ -384,13 +393,285 @@ def _system_editor_command(path: Path) -> list[str]:
|
|
|
384
393
|
return ["xdg-open", str(path)]
|
|
385
394
|
|
|
386
395
|
|
|
396
|
+
def _run_read_only_git(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
397
|
+
command = ["git", *args]
|
|
398
|
+
git_runner = runner or subprocess.run
|
|
399
|
+
return git_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
403
|
+
command = ["cdx", *args]
|
|
404
|
+
cdx_runner = runner or subprocess.run
|
|
405
|
+
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _logics_doc_type(rel_path: str) -> str:
|
|
409
|
+
normalized = rel_path.replace("\\", "/").lstrip("/")
|
|
410
|
+
for family in DOC_FAMILIES:
|
|
411
|
+
if normalized.startswith(f"{family.directory}/"):
|
|
412
|
+
return family.stage
|
|
413
|
+
return ""
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _sanitize_git_ref(value: str) -> str:
|
|
417
|
+
ref = value.strip()
|
|
418
|
+
ref = re.sub(r"://[^/@\s]+@", "://", ref)
|
|
419
|
+
ref = re.sub(r"^[^/@\s]+@", "", ref)
|
|
420
|
+
return ref[:200]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
|
|
424
|
+
if not line or line.startswith("## "):
|
|
425
|
+
return None
|
|
426
|
+
if line.startswith("?? "):
|
|
427
|
+
path = line[3:].strip()
|
|
428
|
+
return "untracked", {"path": path, "logicsType": _logics_doc_type(path)}
|
|
429
|
+
if len(line) < 4:
|
|
430
|
+
return None
|
|
431
|
+
staged = line[0]
|
|
432
|
+
worktree = line[1]
|
|
433
|
+
raw_path = line[3:].strip()
|
|
434
|
+
if " -> " in raw_path:
|
|
435
|
+
before, after = raw_path.split(" -> ", 1)
|
|
436
|
+
path = after.strip()
|
|
437
|
+
return "renamed", {"path": path, "from": before.strip(), "logicsType": _logics_doc_type(path)}
|
|
438
|
+
if staged == "R":
|
|
439
|
+
return "renamed", {"path": raw_path, "logicsType": _logics_doc_type(raw_path)}
|
|
440
|
+
if staged not in {" ", "?", "!"}:
|
|
441
|
+
return "staged", {"path": raw_path, "code": staged, "logicsType": _logics_doc_type(raw_path)}
|
|
442
|
+
if worktree == "D":
|
|
443
|
+
return "deleted", {"path": raw_path, "code": worktree, "logicsType": _logics_doc_type(raw_path)}
|
|
444
|
+
if worktree not in {" ", "?", "!"}:
|
|
445
|
+
return "modified", {"path": raw_path, "code": worktree, "logicsType": _logics_doc_type(raw_path)}
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _parse_git_branch_line(line: str) -> dict[str, Any]:
|
|
450
|
+
branch = line[3:].strip() if line.startswith("## ") else ""
|
|
451
|
+
tracking = ""
|
|
452
|
+
ahead = 0
|
|
453
|
+
behind = 0
|
|
454
|
+
if "..." in branch:
|
|
455
|
+
branch, tracking_part = branch.split("...", 1)
|
|
456
|
+
if " [" in tracking_part:
|
|
457
|
+
tracking, details = tracking_part.split(" [", 1)
|
|
458
|
+
for detail in details.rstrip("]").split(", "):
|
|
459
|
+
if detail.startswith("ahead "):
|
|
460
|
+
ahead = int(detail.removeprefix("ahead ") or "0")
|
|
461
|
+
if detail.startswith("behind "):
|
|
462
|
+
behind = int(detail.removeprefix("behind ") or "0")
|
|
463
|
+
else:
|
|
464
|
+
tracking = tracking_part
|
|
465
|
+
return {
|
|
466
|
+
"branch": _sanitize_git_ref(branch or "HEAD"),
|
|
467
|
+
"tracking": _sanitize_git_ref(tracking),
|
|
468
|
+
"ahead": ahead,
|
|
469
|
+
"behind": behind,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
|
|
474
|
+
commits: list[dict[str, str]] = []
|
|
475
|
+
for line in output.splitlines():
|
|
476
|
+
parts = line.split("\x1f")
|
|
477
|
+
if len(parts) < 5:
|
|
478
|
+
continue
|
|
479
|
+
commit_hash, subject, author, date, refs = parts[:5]
|
|
480
|
+
commits.append(
|
|
481
|
+
{
|
|
482
|
+
"hash": _sanitize_git_ref(commit_hash),
|
|
483
|
+
"subject": subject.strip()[:240],
|
|
484
|
+
"author": author.strip()[:120],
|
|
485
|
+
"date": date.strip()[:40],
|
|
486
|
+
"refs": _sanitize_git_ref(refs),
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
return commits
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _count_unique_git_status_paths(groups: dict[str, list[dict[str, str]]]) -> int:
|
|
493
|
+
paths: set[str] = set()
|
|
494
|
+
for entries in groups.values():
|
|
495
|
+
for entry in entries:
|
|
496
|
+
path = entry.get("path", "").strip()
|
|
497
|
+
if path:
|
|
498
|
+
paths.add(path)
|
|
499
|
+
return len(paths)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _git_unpushed_commit_count(repo_root: Path, *, runner: Any | None = None) -> dict[str, Any]:
|
|
503
|
+
try:
|
|
504
|
+
upstream = _run_read_only_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], runner=runner)
|
|
505
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
506
|
+
return {"available": False, "count": 0, "message": f"Unable to inspect upstream: {exc}"}
|
|
507
|
+
if upstream.returncode != 0:
|
|
508
|
+
return {"available": False, "count": 0, "message": "No upstream branch detected."}
|
|
509
|
+
|
|
510
|
+
tracking = _sanitize_git_ref(upstream.stdout.strip())
|
|
511
|
+
try:
|
|
512
|
+
unpushed = _run_read_only_git(repo_root, ["rev-list", "--count", "@{u}..HEAD"], runner=runner)
|
|
513
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
514
|
+
return {"available": False, "count": 0, "tracking": tracking, "message": f"Unable to count unpushed commits: {exc}"}
|
|
515
|
+
if unpushed.returncode != 0:
|
|
516
|
+
message = (unpushed.stderr or unpushed.stdout or "Unable to count unpushed commits.").strip().splitlines()[0]
|
|
517
|
+
return {"available": False, "count": 0, "tracking": tracking, "message": message}
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
count = max(0, int(unpushed.stdout.strip() or "0"))
|
|
521
|
+
except ValueError:
|
|
522
|
+
count = 0
|
|
523
|
+
return {"available": True, "count": count, "tracking": tracking, "message": ""}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
527
|
+
git_which = which or shutil.which
|
|
528
|
+
if not git_which("git"):
|
|
529
|
+
return {"state": "unavailable", "message": "Git is not available on PATH."}
|
|
530
|
+
try:
|
|
531
|
+
inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
532
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
533
|
+
return {"state": "error", "message": f"Unable to run Git status: {exc}"}
|
|
534
|
+
if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
|
|
535
|
+
return {"state": "not-repository", "message": "This folder is not inside a Git worktree."}
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
status = _run_read_only_git(repo_root, ["status", "--porcelain=v1", "-b"], runner=runner)
|
|
539
|
+
commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
|
|
540
|
+
recent_commits = _run_read_only_git(
|
|
541
|
+
repo_root,
|
|
542
|
+
["log", "-8", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
|
|
543
|
+
runner=runner,
|
|
544
|
+
)
|
|
545
|
+
unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
|
|
546
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
547
|
+
return {"state": "error", "message": f"Unable to collect Git status: {exc}"}
|
|
548
|
+
if status.returncode != 0:
|
|
549
|
+
message = (status.stderr or status.stdout or "Git status failed.").strip().splitlines()[0]
|
|
550
|
+
return {"state": "error", "message": message}
|
|
551
|
+
|
|
552
|
+
lines = status.stdout.splitlines()
|
|
553
|
+
branch_info = _parse_git_branch_line(lines[0]) if lines else {"branch": "HEAD", "tracking": "", "ahead": 0, "behind": 0}
|
|
554
|
+
groups: dict[str, list[dict[str, str]]] = {key: [] for key in ("staged", "modified", "deleted", "renamed", "untracked")}
|
|
555
|
+
for line in lines[1:]:
|
|
556
|
+
classified = _classify_porcelain_entry(line)
|
|
557
|
+
if classified:
|
|
558
|
+
group, entry = classified
|
|
559
|
+
groups[group].append(entry)
|
|
560
|
+
counts = {key: len(value) for key, value in groups.items()}
|
|
561
|
+
uncommitted_files = _count_unique_git_status_paths(groups)
|
|
562
|
+
dirty = any(counts.values())
|
|
563
|
+
return {
|
|
564
|
+
"state": "ok",
|
|
565
|
+
**branch_info,
|
|
566
|
+
"clean": not dirty,
|
|
567
|
+
"dirty": dirty,
|
|
568
|
+
"counts": counts,
|
|
569
|
+
"badgeCounts": {
|
|
570
|
+
"unpushedCommits": int(unpushed.get("count", 0)),
|
|
571
|
+
"uncommittedFiles": uncommitted_files,
|
|
572
|
+
},
|
|
573
|
+
"badgeAvailability": {
|
|
574
|
+
"unpushedCommits": bool(unpushed.get("available")),
|
|
575
|
+
"uncommittedFiles": True,
|
|
576
|
+
},
|
|
577
|
+
"badgeMessages": {
|
|
578
|
+
"unpushedCommits": str(unpushed.get("message", "")),
|
|
579
|
+
"uncommittedFiles": "",
|
|
580
|
+
},
|
|
581
|
+
"groups": groups,
|
|
582
|
+
"latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
|
|
583
|
+
"recentCommits": _parse_recent_git_commits(recent_commits.stdout) if recent_commits.returncode == 0 else [],
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def git_diff_payload(
|
|
588
|
+
repo_root: Path,
|
|
589
|
+
rel_path: str,
|
|
590
|
+
*,
|
|
591
|
+
cached: bool = False,
|
|
592
|
+
max_chars: int = 20000,
|
|
593
|
+
runner: Any | None = None,
|
|
594
|
+
which: Any | None = None,
|
|
595
|
+
) -> dict[str, Any]:
|
|
596
|
+
git_which = which or shutil.which
|
|
597
|
+
if not git_which("git"):
|
|
598
|
+
return {"state": "unavailable", "message": "Git is not available on PATH."}
|
|
599
|
+
normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
|
|
600
|
+
if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
|
|
601
|
+
return {"state": "error", "message": "Unsafe Git path."}
|
|
602
|
+
try:
|
|
603
|
+
inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
604
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
605
|
+
return {"state": "error", "message": f"Unable to run Git diff: {exc}"}
|
|
606
|
+
if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
|
|
607
|
+
return {"state": "not-repository", "message": "This folder is not inside a Git worktree."}
|
|
608
|
+
|
|
609
|
+
args = ["diff", "--no-ext-diff", "--unified=80"]
|
|
610
|
+
if cached:
|
|
611
|
+
args.append("--cached")
|
|
612
|
+
args.extend(["--", normalized])
|
|
613
|
+
try:
|
|
614
|
+
diff = _run_read_only_git(repo_root, args, runner=runner)
|
|
615
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
616
|
+
return {"state": "error", "message": f"Unable to collect Git diff: {exc}"}
|
|
617
|
+
if diff.returncode != 0:
|
|
618
|
+
message = (diff.stderr or diff.stdout or "Git diff failed.").strip().splitlines()[0]
|
|
619
|
+
return {"state": "error", "message": message}
|
|
620
|
+
content = diff.stdout
|
|
621
|
+
truncated = len(content) > max_chars
|
|
622
|
+
if truncated:
|
|
623
|
+
content = content[:max_chars]
|
|
624
|
+
return {
|
|
625
|
+
"state": "ok",
|
|
626
|
+
"path": normalized,
|
|
627
|
+
"mode": "staged" if cached else "worktree",
|
|
628
|
+
"diff": content,
|
|
629
|
+
"truncated": truncated,
|
|
630
|
+
"logicsType": _logics_doc_type(normalized),
|
|
631
|
+
"message": "" if content else "No diff is available for this file in the selected mode.",
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
636
|
+
cdx_which = which or shutil.which
|
|
637
|
+
if not cdx_which("cdx"):
|
|
638
|
+
return {"state": "unavailable", "message": "CDX is not available on PATH.", "status": {}}
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
status = _run_read_only_cdx(repo_root, ["status", "--json"], runner=runner)
|
|
642
|
+
except subprocess.TimeoutExpired:
|
|
643
|
+
return {"state": "timeout", "message": "CDX status timed out.", "status": {}}
|
|
644
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
645
|
+
return {"state": "error", "message": f"Unable to run CDX status: {exc}", "status": {}}
|
|
646
|
+
|
|
647
|
+
if status.returncode != 0:
|
|
648
|
+
message = (status.stderr or status.stdout or "CDX status failed.").strip().splitlines()[0]
|
|
649
|
+
return {"state": "error", "message": message, "status": {}}
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
parsed = json.loads(status.stdout or "{}")
|
|
653
|
+
except json.JSONDecodeError:
|
|
654
|
+
return {"state": "invalid-json", "message": "CDX status returned invalid JSON.", "status": {}}
|
|
655
|
+
if not isinstance(parsed, dict):
|
|
656
|
+
return {"state": "invalid-json", "message": "CDX status JSON must be an object.", "status": {}}
|
|
657
|
+
|
|
658
|
+
return {"state": "ok", "message": "", "status": parsed}
|
|
659
|
+
|
|
660
|
+
|
|
387
661
|
def _json_bytes(payload: Any) -> bytes:
|
|
388
662
|
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
389
663
|
|
|
390
664
|
|
|
391
665
|
class LogicsViewerServer(ThreadingHTTPServer):
|
|
392
|
-
def __init__(
|
|
666
|
+
def __init__(
|
|
667
|
+
self,
|
|
668
|
+
server_address: tuple[str, int],
|
|
669
|
+
repo_root: Path,
|
|
670
|
+
*,
|
|
671
|
+
auto_refresh_interval_seconds: int = 15,
|
|
672
|
+
):
|
|
393
673
|
self.repo_root = repo_root.resolve()
|
|
674
|
+
self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
|
|
394
675
|
super().__init__(server_address, LogicsViewerRequestHandler)
|
|
395
676
|
|
|
396
677
|
|
|
@@ -453,7 +734,15 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
453
734
|
self._serve_file(media_path)
|
|
454
735
|
return
|
|
455
736
|
if route == "/api/items":
|
|
456
|
-
self._send_json(
|
|
737
|
+
self._send_json(
|
|
738
|
+
{
|
|
739
|
+
"ok": True,
|
|
740
|
+
"payload": viewer_data_payload(
|
|
741
|
+
self.server.repo_root,
|
|
742
|
+
auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
|
|
743
|
+
),
|
|
744
|
+
}
|
|
745
|
+
)
|
|
457
746
|
return
|
|
458
747
|
if route == "/api/doc":
|
|
459
748
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
@@ -468,12 +757,32 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
468
757
|
if route == "/api/audit":
|
|
469
758
|
self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
|
|
470
759
|
return
|
|
760
|
+
if route == "/api/git-status":
|
|
761
|
+
self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
|
|
762
|
+
return
|
|
763
|
+
if route == "/api/cdx-status":
|
|
764
|
+
self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
|
|
765
|
+
return
|
|
766
|
+
if route == "/api/git-diff":
|
|
767
|
+
params = parse_qs(parsed.query)
|
|
768
|
+
rel_path = params.get("path", [""])[0]
|
|
769
|
+
cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
|
|
770
|
+
self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
|
|
771
|
+
return
|
|
471
772
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
472
773
|
|
|
473
774
|
def do_POST(self) -> None:
|
|
474
775
|
parsed = urlparse(self.path)
|
|
475
776
|
if parsed.path == "/api/refresh":
|
|
476
|
-
self._send_json(
|
|
777
|
+
self._send_json(
|
|
778
|
+
{
|
|
779
|
+
"ok": True,
|
|
780
|
+
"payload": viewer_data_payload(
|
|
781
|
+
self.server.repo_root,
|
|
782
|
+
auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
|
|
783
|
+
),
|
|
784
|
+
}
|
|
785
|
+
)
|
|
477
786
|
return
|
|
478
787
|
if parsed.path == "/api/edit":
|
|
479
788
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
@@ -487,19 +796,52 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
487
796
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
488
797
|
|
|
489
798
|
|
|
490
|
-
def create_viewer_server(
|
|
491
|
-
|
|
799
|
+
def create_viewer_server(
|
|
800
|
+
repo_root: Path,
|
|
801
|
+
host: str = "127.0.0.1",
|
|
802
|
+
port: int = 8765,
|
|
803
|
+
*,
|
|
804
|
+
auto_refresh_interval_seconds: int = 15,
|
|
805
|
+
) -> LogicsViewerServer:
|
|
806
|
+
return LogicsViewerServer(
|
|
807
|
+
(host, port),
|
|
808
|
+
repo_root,
|
|
809
|
+
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
|
|
810
|
+
)
|
|
492
811
|
|
|
493
812
|
|
|
494
|
-
def
|
|
813
|
+
def _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
|
|
814
|
+
if host not in {"0.0.0.0", "::", ""}:
|
|
815
|
+
return None
|
|
816
|
+
try:
|
|
817
|
+
candidate = socket.gethostbyname(socket.gethostname())
|
|
818
|
+
except OSError:
|
|
819
|
+
return None
|
|
820
|
+
if not candidate or candidate.startswith("127."):
|
|
821
|
+
return None
|
|
822
|
+
return build_viewer_url(candidate, port, focus=focus, read=read)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def render_start_status(
|
|
826
|
+
url: str,
|
|
827
|
+
repo_root: Path,
|
|
828
|
+
*,
|
|
829
|
+
focus: str | None = None,
|
|
830
|
+
network_url: str | None = None,
|
|
831
|
+
bind_host: str = "localhost",
|
|
832
|
+
auto_refresh_interval_seconds: int = 15,
|
|
833
|
+
) -> str:
|
|
495
834
|
lines = [
|
|
496
835
|
"Logics viewer running:",
|
|
497
|
-
url,
|
|
836
|
+
f"Local: {url}",
|
|
498
837
|
"",
|
|
499
838
|
f"Repo: {repo_root.name}",
|
|
500
839
|
"Mode: read-only",
|
|
501
|
-
"Bind:
|
|
840
|
+
f"Bind: {bind_host}",
|
|
841
|
+
f"Auto refresh: {auto_refresh_interval_seconds}s",
|
|
502
842
|
]
|
|
843
|
+
if network_url:
|
|
844
|
+
lines.insert(2, f"Network: {network_url}")
|
|
503
845
|
if focus:
|
|
504
846
|
lines.append(f"Focus: {focus}")
|
|
505
847
|
return "\n".join(lines)
|
|
@@ -509,6 +851,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
509
851
|
parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
|
|
510
852
|
parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
|
|
511
853
|
parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
|
|
854
|
+
parser.add_argument(
|
|
855
|
+
"--refresh-interval",
|
|
856
|
+
type=int,
|
|
857
|
+
default=15,
|
|
858
|
+
help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
|
|
859
|
+
)
|
|
512
860
|
parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
|
|
513
861
|
parser.add_argument("--read", action="store_true", help="Open the focused item in the read preview. Requires --focus.")
|
|
514
862
|
parser.add_argument("--open", action="store_true", help="Open the viewer in the default browser.")
|
|
@@ -519,16 +867,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
519
867
|
def main(argv: list[str]) -> int:
|
|
520
868
|
args = build_parser().parse_args(argv)
|
|
521
869
|
repo_root = find_repo_root(Path.cwd())
|
|
870
|
+
if args.refresh_interval <= 0:
|
|
871
|
+
raise SystemExit("--refresh-interval must be a positive number of seconds.")
|
|
522
872
|
if args.read and not args.focus:
|
|
523
873
|
raise SystemExit("--read requires --focus.")
|
|
524
874
|
try:
|
|
525
875
|
focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
|
|
526
876
|
except ValueError as exc:
|
|
527
877
|
raise SystemExit(str(exc)) from exc
|
|
528
|
-
server = create_viewer_server(
|
|
878
|
+
server = create_viewer_server(
|
|
879
|
+
repo_root,
|
|
880
|
+
host=args.host,
|
|
881
|
+
port=args.port,
|
|
882
|
+
auto_refresh_interval_seconds=args.refresh_interval,
|
|
883
|
+
)
|
|
529
884
|
host, port = server.server_address[:2]
|
|
530
885
|
url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
531
|
-
|
|
886
|
+
network_url = _network_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
887
|
+
print(
|
|
888
|
+
render_start_status(
|
|
889
|
+
url,
|
|
890
|
+
repo_root,
|
|
891
|
+
focus=focus,
|
|
892
|
+
network_url=network_url,
|
|
893
|
+
bind_host=str(host),
|
|
894
|
+
auto_refresh_interval_seconds=args.refresh_interval,
|
|
895
|
+
),
|
|
896
|
+
flush=True,
|
|
897
|
+
)
|
|
532
898
|
if args.open and not args.no_open:
|
|
533
899
|
webbrowser.open(url)
|
|
534
900
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@grifhinz/logics-manager",
|
|
3
3
|
"displayName": "Logics Orchestrator",
|
|
4
4
|
"description": "Visual orchestration for Logics workflows inside VS Code.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.5.0",
|
|
6
6
|
"publisher": "cdx-logics",
|
|
7
7
|
"icon": "clients/shared-web/media/icon.png",
|
|
8
8
|
"repository": {
|
|
@@ -127,6 +127,7 @@
|
|
|
127
127
|
"test:coverage:media": "node scripts/run-plugin-coverage.mjs media",
|
|
128
128
|
"test:coverage": "npm run test:coverage:src && npm run test:coverage:media",
|
|
129
129
|
"test:smoke": "node tests/run_extension_smoke_checks.mjs",
|
|
130
|
+
"test:viewer-smoke": "node tests/run_local_viewer_visual_smoke.mjs",
|
|
130
131
|
"test:npm-cli": "node scripts/npm/logics-manager.mjs --help",
|
|
131
132
|
"test:lifecycle": "node tests/run_plugin_lifecycle_checks.mjs",
|
|
132
133
|
"test:watch": "vitest",
|
|
@@ -155,14 +156,15 @@
|
|
|
155
156
|
"@types/vscode": "^1.86.0",
|
|
156
157
|
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
|
157
158
|
"@typescript-eslint/parser": "^8.58.1",
|
|
158
|
-
"@vscode/vsce": "^3.9.1",
|
|
159
159
|
"@vitest/coverage-v8": "^4.1.2",
|
|
160
|
+
"@vscode/vsce": "^3.9.1",
|
|
160
161
|
"esbuild": "^0.25.10",
|
|
161
162
|
"eslint": "^10.2.0",
|
|
162
163
|
"jsdom": "^25.0.1",
|
|
163
164
|
"mermaid": "^11.14.0",
|
|
164
165
|
"typescript": "^5.3.3",
|
|
165
166
|
"vitest": "^4.1.2",
|
|
167
|
+
"ws": "8.21.0",
|
|
166
168
|
"yaml": "^2.8.3",
|
|
167
169
|
"yauzl": "^3.2.0"
|
|
168
170
|
},
|