@grifhinz/logics-manager 2.9.1 → 2.9.3

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml)
4
4
  [![License](https://img.shields.io/github/license/AlexAgo83/logics-manager)](LICENSE)
5
- ![Version](https://img.shields.io/badge/version-v2.9.1-4C8BF5)
5
+ ![Version](https://img.shields.io/badge/version-v2.9.3-4C8BF5)
6
6
  ![VS Code](https://img.shields.io/badge/VS%20Code-1.86.0-007ACC?logo=visualstudiocode&logoColor=white)
7
7
  ![TypeScript](https://img.shields.io/badge/TypeScript-5.3.3-3178C6?logo=typescript&logoColor=white)
8
8
  ![Vitest](https://img.shields.io/badge/Vitest-4.1.2-6E9F18?logo=vitest&logoColor=white)
@@ -51,7 +51,21 @@ The CLI owns the behavior. The extension and MCP server call into it instead of
51
51
 
52
52
  ## Quick Start
53
53
 
54
- Install the CLI from this repository:
54
+ Install the CLI from PyPI:
55
+
56
+ ```bash
57
+ python3.11 -m pip install logics-manager
58
+ logics-manager --help
59
+ ```
60
+
61
+ For an isolated user-level install, use `pipx`:
62
+
63
+ ```bash
64
+ pipx install logics-manager
65
+ logics-manager --help
66
+ ```
67
+
68
+ Install the CLI from this repository when developing locally:
55
69
 
56
70
  ```bash
57
71
  python3.11 -m pip install .
@@ -67,7 +81,7 @@ pipx ensurepath
67
81
  pipx install logics-manager
68
82
  ```
69
83
 
70
- Or install the npm package:
84
+ Or install the npm package from npm:
71
85
 
72
86
  ```bash
73
87
  npm install -g @grifhinz/logics-manager
@@ -151,6 +165,17 @@ logics-manager view --open
151
165
 
152
166
  The viewer starts a localhost-only browser UI on `127.0.0.1:8765` by default. It shows the same workflow board/list experience as the extension, with search, filters, document previews, corpus insights, lint/audit health, Mermaid rendering, auto-refresh, and an edit shortcut that opens the selected Markdown file in the system editor.
153
167
 
168
+ The topbar includes focused operational views:
169
+
170
+ | View | Purpose |
171
+ | --- | --- |
172
+ | Explorer | Read-only repository tree with bounded previews for text, images, directories, oversized files, and unsupported binary files. |
173
+ | Workshop | Local terminals and command runs. Terminals use the vendored xterm.js frontend; commands are discovered from `package.json` and `pyproject.toml` scripts and stream output over SSE. |
174
+ | Git | Repository status, changed files, and diffs for review-oriented inspection. |
175
+ | CI | Local/remote validation status surfaced for release and handoff checks. |
176
+ | CDX | Guarded assistant workflows for audits, release reviews, corpus planning, and pre-release preparation. |
177
+ | Settings | Viewer preferences, display controls, refresh behavior, and local UI state. |
178
+
154
179
  Viewer preferences are stored locally in the browser profile. Auto-refresh
155
180
  restores the interval chosen in the viewer unless the launch command explicitly
156
181
  sets `--refresh-interval`, in which case that launch value controls only the
@@ -161,6 +186,24 @@ When workspace inspection is available, the topbar shows an `Explorer` view
161
186
  before `Git`; it provides a read-only file tree and bounded previews for text,
162
187
  directories, images, oversized files, and unsupported binary files.
163
188
 
189
+ The Workshop view is local-machine only. Terminal sessions and command runs are
190
+ created on the machine running `logics-manager view`, appear with running-count
191
+ badges in the topbar, and can be stopped from the UI. Terminal sessions are
192
+ cleaned up after the browser disconnects, while quick reloads can reattach
193
+ without leaving duplicate sessions behind.
194
+
195
+ For phone or tablet inspection on the same trusted network, launch with `--lan`:
196
+
197
+ ```bash
198
+ logics-manager view --lan --open
199
+ ```
200
+
201
+ LAN mode binds to `0.0.0.0`, computes a reachable local-network URL, and adds a
202
+ per-session bearer token for non-loopback requests. The browser receives a
203
+ shareable URL and, when the optional `segno` package is installed, a QR code.
204
+ LAN mode is still read-only at the HTTP layer; workflow mutations continue to go
205
+ through canonical CLI commands.
206
+
164
207
  The CDX missions panel includes guarded workflows for audits, release reviews,
165
208
  turning a free-form wish into a structured Logics request, preparing a corpus
166
209
  plan, and preparing a guarded pre-release from an editable `vX.X.X` version.
@@ -177,6 +220,7 @@ Useful options:
177
220
 
178
221
  ```bash
179
222
  logics-manager view --port 0 --open
223
+ logics-manager view --lan --port 0 --open
180
224
  logics-manager view --host 127.0.0.1 --port 9876
181
225
  logics-manager view --focus req_001_example --open
182
226
  logics-manager view --focus logics/tasks/task_001_example.md --read --open
@@ -284,6 +328,12 @@ npm list -g @grifhinz/logics-manager --depth=0
284
328
 
285
329
  If the direct npm binary shows the expected version, remove the older Python install or move the npm global `bin` directory earlier on `PATH`. In zsh, run `rehash` or open a new terminal after changing installs so the shell forgets any cached command location.
286
330
 
331
+ When installed with `pipx` from a local path, `self-update` reports that original
332
+ spec, for example `from spec '/path/to/logics-manager'`. That installation is
333
+ updated from the local working tree, not from the PyPI artifact. Use
334
+ `pipx uninstall logics-manager && pipx install logics-manager` when you want to
335
+ switch back to the published PyPI package.
336
+
287
337
  ## VS Code Extension
288
338
 
289
339
  The VS Code extension is the human cockpit around the same runtime. It helps you:
@@ -377,6 +427,13 @@ python3 -m logics_manager mcp connect --repo-root . --public-url https://example
377
427
 
378
428
  The connector plan prints the bearer token when used, server command, tunnel target, assistant connector URL, auth mode, auth header, smoke checks, warnings, and cleanup steps.
379
429
 
430
+ ## Security
431
+
432
+ See [SECURITY.md](SECURITY.md) for supported versions and vulnerability
433
+ reporting guidance. Do not publish suspected vulnerabilities in public issues
434
+ until they are triaged; use GitHub's private vulnerability reporting or a
435
+ private security advisory draft for this repository.
436
+
380
437
  ## Assistant Model
381
438
 
382
439
  The project is local-first:
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.9.1
1
+ 2.9.3
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import os
5
6
  import re
6
7
  from copy import deepcopy
7
8
  from dataclasses import dataclass
@@ -43,12 +44,28 @@ APPROVED_WORKFLOW_INDICATORS = ("Status", "Progress", "Understanding", "Confiden
43
44
  MAX_MUTATION_TEXT_CHARS = 2000
44
45
 
45
46
 
46
- def _read_text(path: Path) -> str:
47
- return path.read_text(encoding="utf-8")
47
+ def _read_text(repo_root: Path, path: Path) -> str:
48
+ root = os.path.realpath(repo_root)
49
+ absolute_name = os.path.realpath(path)
50
+ try:
51
+ common = os.path.commonpath([root, absolute_name])
52
+ except ValueError as exc:
53
+ raise SystemExit(f"Unsupported workflow doc path `{path}`.") from exc
54
+ if common != root:
55
+ raise SystemExit(f"Unsupported workflow doc path `{path}`.")
56
+ absolute = Path(absolute_name)
57
+ approved_dirs = {Path(os.path.realpath(repo_root / kind["directory"])) for kind in DOC_KINDS.values()}
58
+ if absolute.parent not in approved_dirs:
59
+ raise SystemExit(f"Unsupported workflow doc path `{path}`.")
60
+ return absolute.read_text(encoding="utf-8")
61
+
62
+
63
+ def _read_lines(repo_root: Path, path: Path) -> list[str]:
64
+ return _read_text(repo_root, path).splitlines()
48
65
 
49
66
 
50
- def _read_lines(path: Path) -> list[str]:
51
- return _read_text(path).splitlines()
67
+ def _is_relative_path(path: Path) -> bool:
68
+ return not path.is_absolute() and ".." not in path.parts
52
69
 
53
70
 
54
71
  def _indicator_value(lines: list[str], key: str) -> str | None:
@@ -128,7 +145,7 @@ def _detect_workflow_kind(path: Path) -> str:
128
145
 
129
146
 
130
147
  def parse_workflow_doc(path: Path, *, repo_root: Path | None = None) -> WorkflowDocModel:
131
- text = _read_text(path)
148
+ text = _read_text(repo_root or path.parent.parent.parent, path)
132
149
  lines = text.splitlines()
133
150
  sections = _extract_sections(text)
134
151
  indicators = {key: value for key in ("From version", "Schema version", "Status", "Understanding", "Confidence", "Progress", "Complexity", "Theme", "Date", "Drivers", "Related request", "Related backlog", "Related task", "Reminder") if (value := _indicator_value(lines, key)) is not None}
@@ -307,6 +324,7 @@ def _build_context_pack(
307
324
 
308
325
 
309
326
  def _resolve_target_docs(repo_root: Path, sources: list[str]) -> list[tuple[str, Path]]:
327
+ candidates: dict[str, tuple[str, Path]] = {}
310
328
  if not sources:
311
329
  targets: list[tuple[str, Path]] = []
312
330
  for kind_name, kind in DOC_KINDS.items():
@@ -317,25 +335,25 @@ def _resolve_target_docs(repo_root: Path, sources: list[str]) -> list[tuple[str,
317
335
  targets.append((kind_name, path))
318
336
  return targets
319
337
 
338
+ for kind_name, kind in DOC_KINDS.items():
339
+ directory = repo_root / kind["directory"]
340
+ if not directory.is_dir():
341
+ continue
342
+ for path in sorted(directory.glob(f"{kind['prefix']}_*.md")):
343
+ candidates[path.relative_to(repo_root).as_posix()] = (kind_name, path)
344
+ candidates[path.stem] = (kind_name, path)
345
+
320
346
  resolved: list[tuple[str, Path]] = []
321
347
  for source in sources:
322
348
  raw_source = Path(source)
323
- if raw_source.is_absolute() or any(part == ".." for part in raw_source.parts):
349
+ if not _is_relative_path(raw_source):
324
350
  raise SystemExit(f"Unsupported workflow doc target `{source}`.")
325
- candidate = (repo_root / source).resolve()
326
- if candidate.is_file():
327
- for kind_name, kind in DOC_KINDS.items():
328
- if candidate.parent == (repo_root / kind["directory"]).resolve():
329
- resolved.append((kind_name, candidate))
330
- break
351
+ normalized = raw_source.as_posix()
352
+ target = candidates.get(normalized) or candidates.get(Path(source).stem if "/" not in normalized else "")
353
+ if target is not None:
354
+ resolved.append(target)
331
355
  continue
332
- for kind_name, kind in DOC_KINDS.items():
333
- path = repo_root / kind["directory"] / f"{source}.md"
334
- if path.is_file():
335
- resolved.append((kind_name, path))
336
- break
337
- else:
338
- raise SystemExit(f"Could not resolve workflow doc target `{source}`.")
356
+ raise SystemExit(f"Could not resolve workflow doc target `{source}`.")
339
357
  return resolved
340
358
 
341
359
 
@@ -385,7 +403,7 @@ def read_logics_doc_payload(repo_root: Path, source: str, *, max_chars: int = 40
385
403
  for heading in requested_sections
386
404
  if heading in doc.sections
387
405
  }
388
- text = _read_text(path)
406
+ text = _read_text(repo_root, path)
389
407
  return {
390
408
  "ref": doc.ref,
391
409
  "kind": doc.kind,
@@ -464,7 +482,7 @@ def search_logics_docs_payload(
464
482
  doc = docs_by_ref.get(ref)
465
483
  if doc is None:
466
484
  continue
467
- text = _strip_mermaid_blocks(_read_text(repo_root / doc.path))
485
+ text = _strip_mermaid_blocks(_read_text(repo_root, repo_root / doc.path))
468
486
  lines = text.splitlines()
469
487
  for idx, line in enumerate(lines):
470
488
  if normalized_query in line.lower():
@@ -531,7 +549,7 @@ def update_workflow_indicators_payload(repo_root: Path, source: str, indicators:
531
549
  if len(targets) != 1:
532
550
  raise SystemExit(f"Expected one workflow doc target for `{source}`.")
533
551
  kind, path = targets[0]
534
- lines = _read_lines(path)
552
+ lines = _read_lines(repo_root, path)
535
553
  changed = False
536
554
  for key in APPROVED_WORKFLOW_INDICATORS:
537
555
  if key not in cleaned:
@@ -573,7 +591,7 @@ def append_workflow_note_payload(repo_root: Path, source: str, *, note_kind: str
573
591
  section = _section_for_note(kind, note_kind)
574
592
  cleaned = _clean_mutation_text(text, field="text")
575
593
  bullet = f"- {cleaned}"
576
- lines = _read_lines(path)
594
+ lines = _read_lines(repo_root, path)
577
595
  insert_at = None
578
596
  for idx, line in enumerate(lines):
579
597
  if line.startswith("# ") and line[2:].strip().lower() == section.lower():
@@ -631,13 +649,13 @@ def _collect_docs_linking_ref(repo_root: Path, kind: str, ref: str) -> list[Path
631
649
  directory = repo_root / DOC_KINDS[kind]["directory"]
632
650
  linked: list[Path] = []
633
651
  for path in sorted(directory.glob("*.md")):
634
- if ref in _read_text(path):
652
+ if ref in _read_text(repo_root, path):
635
653
  linked.append(path)
636
654
  return linked
637
655
 
638
656
 
639
- def _is_doc_done(path: Path, kind: str) -> bool:
640
- lines = _read_lines(path)
657
+ def _is_doc_done(repo_root: Path, path: Path, kind: str) -> bool:
658
+ lines = _read_lines(repo_root, path)
641
659
  status_value = _indicator_value(lines, "Status")
642
660
  if status_value is not None and " ".join(status_value.split()).lower() in {"done", "archived"}:
643
661
  return True
@@ -648,10 +666,10 @@ def _is_doc_done(path: Path, kind: str) -> bool:
648
666
  return False
649
667
 
650
668
 
651
- def _close_doc(path: Path, kind: str, dry_run: bool) -> None:
669
+ def _close_doc(repo_root: Path, path: Path, kind: str, dry_run: bool) -> None:
652
670
  if dry_run:
653
671
  return
654
- lines = _read_lines(path)
672
+ lines = _read_lines(repo_root, path)
655
673
  updated = []
656
674
  saw_status = False
657
675
  saw_progress = False
@@ -696,7 +714,7 @@ def _refresh_workflow_mermaid_signature_text(text: str, kind: str, *, repo_root:
696
714
 
697
715
 
698
716
  def refresh_workflow_mermaid_signature_file(path: Path, kind: str, dry_run: bool, *, repo_root: Path | None = None) -> bool:
699
- original = _read_text(path)
717
+ original = _read_text(repo_root or path.parent.parent.parent, path)
700
718
  refreshed, changed = _refresh_workflow_mermaid_signature_text(original, kind, repo_root=repo_root, dry_run=dry_run)
701
719
  if not changed:
702
720
  return False
@@ -711,14 +729,14 @@ def _close_eligible_requests(repo_root: Path, dry_run: bool, *, quiet: bool = Fa
711
729
  scanned = 0
712
730
  for request_path in sorted(request_dir.glob("req_*.md")):
713
731
  scanned += 1
714
- if _is_doc_done(request_path, "request"):
732
+ if _is_doc_done(repo_root, request_path, "request"):
715
733
  continue
716
734
  request_ref = request_path.stem
717
735
  linked_items = _collect_docs_linking_ref(repo_root, "backlog", request_ref)
718
736
  if not linked_items:
719
737
  continue
720
- if all(_is_doc_done(item_path, "backlog") for item_path in linked_items):
721
- _close_doc(request_path, "request", dry_run)
738
+ if all(_is_doc_done(repo_root, item_path, "backlog") for item_path in linked_items):
739
+ _close_doc(repo_root, request_path, "request", dry_run)
722
740
  if not quiet:
723
741
  print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
724
742
  closed += 1
@@ -692,8 +692,10 @@ def _resolve_repo_doc_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
692
692
  def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = None) -> dict[str, str]:
693
693
  normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
694
694
  command = _system_editor_command(absolute)
695
- runner = launcher or subprocess.Popen
696
- runner(command)
695
+ if launcher is not None:
696
+ launcher(command)
697
+ else:
698
+ webbrowser.open(absolute.as_uri())
697
699
  return {
698
700
  "path": normalized,
699
701
  "command": command[0],
@@ -701,13 +703,25 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
701
703
 
702
704
 
703
705
  def _resolve_openable_file_path(repo_root: Path, file_path: str) -> Path:
704
- raw_path = unquote(file_path).strip()
705
- if not raw_path:
706
+ raw_value = unquote(file_path).strip()
707
+ if raw_value.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", raw_value):
708
+ raise ValueError("File path escapes repository root.")
709
+ normalized = raw_value.replace("\\", "/").lstrip("/")
710
+ if not normalized:
706
711
  raise ValueError("Missing file path.")
707
- candidate = Path(raw_path).expanduser()
708
- if not candidate.is_absolute():
709
- candidate = repo_root / raw_path.lstrip("/\\")
710
- absolute = candidate.resolve()
712
+ raw_parts = tuple(part for part in normalized.split("/") if part)
713
+ if any(part == ".." for part in raw_parts):
714
+ raise ValueError("File path escapes repository root.")
715
+ candidate = repo_root.joinpath(*raw_parts)
716
+ root = os.path.realpath(repo_root)
717
+ absolute_name = os.path.realpath(candidate)
718
+ try:
719
+ common = os.path.commonpath([root, absolute_name])
720
+ except ValueError as exc:
721
+ raise ValueError("File path escapes repository root.") from exc
722
+ if common != root:
723
+ raise ValueError("File path escapes repository root.")
724
+ absolute = Path(absolute_name)
711
725
  if not absolute.is_file():
712
726
  raise FileNotFoundError(str(candidate))
713
727
  return absolute
@@ -716,8 +730,10 @@ def _resolve_openable_file_path(repo_root: Path, file_path: str) -> Path:
716
730
  def open_file_payload(repo_root: Path, file_path: str, *, launcher: Any | None = None) -> dict[str, str]:
717
731
  absolute = _resolve_openable_file_path(repo_root, file_path)
718
732
  command = _system_editor_command(absolute)
719
- runner = launcher or subprocess.Popen
720
- runner(command)
733
+ if launcher is not None:
734
+ launcher(command)
735
+ else:
736
+ webbrowser.open(absolute.as_uri())
721
737
  return {
722
738
  "path": str(absolute),
723
739
  "command": command[0],
@@ -751,8 +767,10 @@ def file_preview_payload(
751
767
  def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
752
768
  root = repo_root.resolve()
753
769
  command = _system_editor_command(root)
754
- runner = launcher or subprocess.Popen
755
- runner(command)
770
+ if launcher is not None:
771
+ launcher(command)
772
+ else:
773
+ webbrowser.open(root.as_uri())
756
774
  return {
757
775
  "path": str(root),
758
776
  "command": command[0],
@@ -763,10 +781,22 @@ def _system_editor_command(path: Path) -> list[str]:
763
781
  if sys.platform == "darwin":
764
782
  return ["open", str(path)]
765
783
  if os.name == "nt":
766
- return ["cmd", "/c", "start", "", str(path)]
784
+ return ["explorer.exe", str(path)]
767
785
  return ["xdg-open", str(path)]
768
786
 
769
787
 
788
+ STATIC_CONTENT_TYPES = {
789
+ ".css": "text/css; charset=utf-8",
790
+ ".html": "text/html; charset=utf-8",
791
+ ".js": "text/javascript; charset=utf-8",
792
+ ".json": "application/json; charset=utf-8",
793
+ ".map": "application/json; charset=utf-8",
794
+ ".png": "image/png",
795
+ ".svg": "image/svg+xml",
796
+ ".wasm": "application/wasm",
797
+ }
798
+
799
+
770
800
  def _run_read_only_git(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
771
801
  command = ["git", *args]
772
802
  git_runner = runner or subprocess.run
@@ -3074,14 +3104,23 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
3074
3104
  def _send_error_json(self, status: HTTPStatus, message: str) -> None:
3075
3105
  self._send_json({"ok": False, "error": message}, status=status.value)
3076
3106
 
3077
- def _serve_file(self, path: Path) -> None:
3078
- if not path.is_file():
3107
+ def _serve_file(self, path: Path, *, root: Path) -> None:
3108
+ root_name = os.path.realpath(root)
3109
+ absolute_name = os.path.realpath(path)
3110
+ try:
3111
+ common = os.path.commonpath([root_name, absolute_name])
3112
+ except ValueError:
3113
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
3114
+ return
3115
+ if common != root_name:
3116
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
3117
+ return
3118
+ absolute = Path(absolute_name)
3119
+ if not absolute.is_file(): # lgtm [py/path-injection]
3079
3120
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
3080
3121
  return
3081
- content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
3082
- if content_type.startswith("text/") or path.suffix in {".js", ".css", ".html"}:
3083
- content_type = f"{content_type}; charset=utf-8"
3084
- self._send_bytes(path.read_bytes(), content_type=content_type)
3122
+ content_type = STATIC_CONTENT_TYPES.get(absolute.suffix.lower(), "application/octet-stream")
3123
+ self._send_bytes(absolute.read_bytes(), content_type=content_type) # lgtm [py/path-injection]
3085
3124
 
3086
3125
  def _stream_workshop_terminal(self, session: "WorkshopTerminalSession", parsed: Any) -> None:
3087
3126
  import time as _time
@@ -3254,28 +3293,31 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
3254
3293
  return
3255
3294
  route = parsed.path
3256
3295
  if route == "/":
3257
- self._serve_file(VIEWER_ROOT / "index.html")
3296
+ self._serve_file(VIEWER_ROOT / "index.html", root=VIEWER_ROOT)
3258
3297
  return
3259
3298
  if route == "/browser-host.js":
3260
- self._serve_file(VIEWER_ROOT / "browser-host.js")
3299
+ self._serve_file(VIEWER_ROOT / "browser-host.js", root=VIEWER_ROOT)
3261
3300
  return
3262
3301
  if route == "/viewer.css":
3263
- self._serve_file(VIEWER_ROOT / "viewer.css")
3302
+ self._serve_file(VIEWER_ROOT / "viewer.css", root=VIEWER_ROOT)
3264
3303
  return
3265
3304
  if route == "/vendor/mermaid.min.js":
3266
3305
  vendor_path = DIST_VENDOR_ROOT / "mermaid.min.js"
3306
+ vendor_root = DIST_VENDOR_ROOT
3267
3307
  if not vendor_path.is_file():
3268
3308
  vendor_path = NODE_MERMAID_ROOT / "mermaid.min.js"
3309
+ vendor_root = NODE_MERMAID_ROOT
3269
3310
  if not vendor_path.is_file():
3270
3311
  vendor_path = PACKAGE_VENDOR_ROOT / "mermaid.min.js"
3271
- self._serve_file(vendor_path)
3312
+ vendor_root = PACKAGE_VENDOR_ROOT
3313
+ self._serve_file(vendor_path, root=vendor_root)
3272
3314
  return
3273
3315
  if route.startswith("/media/"):
3274
3316
  media_path = (SHARED_MEDIA_ROOT / route.removeprefix("/media/")).resolve()
3275
3317
  if SHARED_MEDIA_ROOT.resolve() != media_path and SHARED_MEDIA_ROOT.resolve() not in media_path.parents:
3276
3318
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
3277
3319
  return
3278
- self._serve_file(media_path)
3320
+ self._serve_file(media_path, root=SHARED_MEDIA_ROOT)
3279
3321
  return
3280
3322
  if route == "/api/items":
3281
3323
  self._send_json(
@@ -3399,7 +3441,7 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
3399
3441
  self._send_error_json(HTTPStatus.BAD_REQUEST, "Workspace file is not an image preview.")
3400
3442
  return
3401
3443
  _normalized, absolute = _resolve_workspace_path(self.server.repo_root, rel_path)
3402
- self._serve_file(absolute)
3444
+ self._serve_file(absolute, root=self.server.repo_root)
3403
3445
  except (FileNotFoundError, ValueError) as exc:
3404
3446
  self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
3405
3447
  return
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.9.1",
5
+ "version": "2.9.3",
6
6
  "publisher": "cdx-logics",
7
7
  "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
@@ -159,7 +159,7 @@
159
159
  "@typescript-eslint/parser": "^8.58.1",
160
160
  "@vitest/coverage-v8": "^4.1.2",
161
161
  "@vscode/vsce": "^3.9.1",
162
- "esbuild": "^0.25.10",
162
+ "esbuild": "^0.28.1",
163
163
  "eslint": "^10.2.0",
164
164
  "jsdom": "^25.0.1",
165
165
  "mermaid": "^11.14.0",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.9.1"
7
+ version = "2.9.3"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10