davinci-resolve-mcp 2.24.1 → 2.26.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/install.py CHANGED
@@ -21,6 +21,7 @@ import shutil
21
21
  import subprocess
22
22
  import sys
23
23
  import textwrap
24
+ import time
24
25
  from pathlib import Path
25
26
 
26
27
  from src.utils.update_check import (
@@ -34,7 +35,7 @@ from src.utils.update_check import (
34
35
 
35
36
  # ─── Version ──────────────────────────────────────────────────────────────────
36
37
 
37
- VERSION = "2.24.1"
38
+ VERSION = "2.26.0"
38
39
  SUPPORTED_PYTHON_MIN = (3, 10)
39
40
  SUPPORTED_PYTHON_MAX = (3, 12)
40
41
 
@@ -654,49 +655,315 @@ def _git_failure_message(result, fallback):
654
655
  return output or fallback
655
656
 
656
657
 
657
- def apply_safe_self_update(project_dir, dry_run=False):
658
- """Apply a guarded git fast-forward update for clean checkouts only."""
658
+ def _record_update_history(project_dir, entry):
659
+ """Append `entry` to `<project_dir>/logs/update_history.json` (best-effort)."""
660
+ import json as _json
661
+ log_dir = os.path.join(project_dir, "logs")
662
+ try:
663
+ os.makedirs(log_dir, exist_ok=True)
664
+ except OSError:
665
+ return
666
+ path = os.path.join(log_dir, "update_history.json")
667
+ history = {"entries": []}
668
+ if os.path.isfile(path):
669
+ try:
670
+ with open(path, "r", encoding="utf-8") as fh:
671
+ loaded = _json.load(fh)
672
+ if isinstance(loaded, dict) and isinstance(loaded.get("entries"), list):
673
+ history = loaded
674
+ except (OSError, ValueError):
675
+ pass
676
+ history.setdefault("entries", []).append(entry)
677
+ # Trim to most recent 200 entries — keep history bounded.
678
+ if len(history["entries"]) > 200:
679
+ history["entries"] = history["entries"][-200:]
680
+ history["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
681
+ tmp_path = path + ".tmp"
682
+ try:
683
+ with open(tmp_path, "w", encoding="utf-8") as fh:
684
+ _json.dump(history, fh, indent=2)
685
+ os.replace(tmp_path, path)
686
+ except OSError:
687
+ try:
688
+ os.remove(tmp_path)
689
+ except OSError:
690
+ pass
691
+
692
+
693
+ def _read_current_version(project_dir):
694
+ """Best-effort read of VERSION constant from src/server.py."""
695
+ server_path = os.path.join(project_dir, "src", "server.py")
696
+ try:
697
+ with open(server_path, "r", encoding="utf-8") as fh:
698
+ for line in fh:
699
+ if line.startswith("VERSION = "):
700
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
701
+ except OSError:
702
+ pass
703
+ return None
704
+
705
+
706
+ def _record_attempt(project_dir, *, kind, success, reason=None, message=None,
707
+ from_version=None, to_version=None, from_sha=None, to_sha=None,
708
+ initiator=None, extra=None):
709
+ """Append a structured row to update_history.json. `extra` is merged in flat."""
710
+ entry = {
711
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
712
+ "kind": kind, # "update" | "rollback" | "dry_run"
713
+ "success": bool(success),
714
+ "reason": reason,
715
+ "message": message,
716
+ "from_version": from_version,
717
+ "to_version": to_version,
718
+ "from_sha": from_sha,
719
+ "to_sha": to_sha,
720
+ "initiator": initiator,
721
+ }
722
+ if isinstance(extra, dict):
723
+ entry.update(extra)
724
+ _record_update_history(project_dir, entry)
725
+
726
+
727
+ def apply_safe_self_update(project_dir, dry_run=False, *, initiator="cli",
728
+ strategy="refuse_on_dirty"):
729
+ """Apply a guarded git fast-forward update.
730
+
731
+ `strategy` controls behavior when the working tree is dirty:
732
+ - `"refuse_on_dirty"` (default) — return reason="local_changes" without
733
+ touching anything.
734
+ - `"stash_if_needed"` — `git stash push`, apply the update, `git stash pop`.
735
+ If pop conflicts, leave the stash in place and return reason="stash_pop_conflict"
736
+ with the stash ref so the user can resolve.
737
+
738
+ Every attempt — success or failure — is recorded in
739
+ `<project_dir>/logs/update_history.json` so the dashboard can show what
740
+ happened and rollback can find the prior SHA.
741
+ """
659
742
  inside = _run_git(project_dir, ["rev-parse", "--is-inside-work-tree"])
660
743
  if inside is None or isinstance(inside, subprocess.TimeoutExpired) or inside.returncode != 0:
661
- return {"success": False, "reason": "not_git", "message": _git_failure_message(inside, "not a git checkout")}
744
+ msg = _git_failure_message(inside, "not a git checkout")
745
+ _record_attempt(project_dir, kind="update", success=False, reason="not_git", message=msg, initiator=initiator)
746
+ return {"success": False, "reason": "not_git", "message": msg}
662
747
 
663
748
  status = _run_git(project_dir, ["status", "--porcelain"])
664
749
  if status is None or isinstance(status, subprocess.TimeoutExpired) or status.returncode != 0:
665
- return {"success": False, "reason": "status_failed", "message": _git_failure_message(status, "could not inspect git status")}
750
+ msg = _git_failure_message(status, "could not inspect git status")
751
+ _record_attempt(project_dir, kind="update", success=False, reason="status_failed", message=msg, initiator=initiator)
752
+ return {"success": False, "reason": "status_failed", "message": msg}
753
+
754
+ stash_ref = None
666
755
  if status.stdout.strip():
667
- return {
668
- "success": False,
669
- "reason": "local_changes",
670
- "message": "local changes are present; continuing with the current build",
671
- }
756
+ if strategy != "stash_if_needed":
757
+ msg = "local changes are present; continuing with the current build"
758
+ _record_attempt(project_dir, kind="update", success=False, reason="local_changes", message=msg, initiator=initiator)
759
+ return {"success": False, "reason": "local_changes", "message": msg}
760
+ # Auto-stash path: push the working tree onto the stash, remember the
761
+ # ref so we can pop (or surface) it after the update.
762
+ stash_msg = f"mcp-update-autostash-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}"
763
+ stash = _run_git(project_dir, ["stash", "push", "-u", "-m", stash_msg], timeout=30)
764
+ if stash is None or stash.returncode != 0 or "No local changes to save" in (stash.stdout or ""):
765
+ # If we got here `status` was dirty, so a stash failure means trouble.
766
+ msg = _git_failure_message(stash, "git stash push failed")
767
+ _record_attempt(project_dir, kind="update", success=False, reason="stash_failed", message=msg, initiator=initiator)
768
+ return {"success": False, "reason": "stash_failed", "message": msg}
769
+ stash_ref = stash_msg
672
770
 
673
771
  upstream = _run_git(project_dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
674
772
  if upstream is None or isinstance(upstream, subprocess.TimeoutExpired) or upstream.returncode != 0:
675
- return {
676
- "success": False,
677
- "reason": "no_upstream",
678
- "message": _git_failure_message(upstream, "current branch has no configured upstream"),
679
- }
773
+ msg = _git_failure_message(upstream, "current branch has no configured upstream")
774
+ _record_attempt(project_dir, kind="update", success=False, reason="no_upstream", message=msg, initiator=initiator)
775
+ return {"success": False, "reason": "no_upstream", "message": msg}
776
+
777
+ # Capture pre-update SHA + version so rollback knows where to revert to.
778
+ head_before = _run_git(project_dir, ["rev-parse", "HEAD"])
779
+ from_sha = head_before.stdout.strip() if head_before and head_before.returncode == 0 else None
780
+ from_version = _read_current_version(project_dir)
680
781
 
681
782
  if dry_run:
682
- return {
683
- "success": True,
684
- "changed": False,
685
- "dry_run": True,
686
- "message": f"would fast-forward from {upstream.stdout.strip()}",
687
- }
783
+ msg = f"would fast-forward from {upstream.stdout.strip()}"
784
+ _record_attempt(project_dir, kind="dry_run", success=True, message=msg,
785
+ from_version=from_version, from_sha=from_sha, initiator=initiator)
786
+ return {"success": True, "changed": False, "dry_run": True, "message": msg}
688
787
 
689
788
  fetch = _run_git(project_dir, ["fetch", "--tags", "--prune"], timeout=90)
690
789
  if fetch is None or isinstance(fetch, subprocess.TimeoutExpired) or fetch.returncode != 0:
691
- return {"success": False, "reason": "fetch_failed", "message": _git_failure_message(fetch, "git fetch failed")}
790
+ msg = _git_failure_message(fetch, "git fetch failed")
791
+ _record_attempt(project_dir, kind="update", success=False, reason="fetch_failed", message=msg,
792
+ from_version=from_version, from_sha=from_sha, initiator=initiator)
793
+ return {"success": False, "reason": "fetch_failed", "message": msg}
692
794
 
693
795
  pull = _run_git(project_dir, ["pull", "--ff-only"], timeout=120)
694
796
  if pull is None or isinstance(pull, subprocess.TimeoutExpired) or pull.returncode != 0:
695
- return {"success": False, "reason": "pull_failed", "message": _git_failure_message(pull, "git pull --ff-only failed")}
797
+ msg = _git_failure_message(pull, "git pull --ff-only failed")
798
+ _record_attempt(project_dir, kind="update", success=False, reason="pull_failed", message=msg,
799
+ from_version=from_version, from_sha=from_sha, initiator=initiator)
800
+ return {"success": False, "reason": "pull_failed", "message": msg}
801
+
802
+ head_after = _run_git(project_dir, ["rev-parse", "HEAD"])
803
+ to_sha = head_after.stdout.strip() if head_after and head_after.returncode == 0 else None
804
+ to_version = _read_current_version(project_dir)
696
805
 
697
806
  output = "\n".join(part.strip() for part in (pull.stdout, pull.stderr) if part and part.strip())
698
807
  changed = "Already up to date." not in output
699
- return {"success": True, "changed": changed, "message": output or "update complete"}
808
+
809
+ # Integrity verification: confirm our local HEAD matches what GitHub said
810
+ # was the target SHA. Mismatch could mean: (a) user pushed to a fork mid-
811
+ # update, (b) the release we resolved was different from the branch tip
812
+ # (force-pushed), (c) corruption. We don't roll back automatically — the
813
+ # user may have intentional reasons — but we log it loudly.
814
+ integrity = {"verified": None, "expected_sha": None, "actual_sha": to_sha}
815
+ try:
816
+ from src.utils.update_check import check_for_updates as _cfu # type: ignore
817
+ info = _cfu(from_version or "0.0.0", project_dir, force=False)
818
+ expected = info.get("release_target_sha")
819
+ if expected:
820
+ integrity["expected_sha"] = expected
821
+ # Compare prefixes (release SHA may be a tag name or short SHA).
822
+ ok = bool(to_sha) and (
823
+ expected == to_sha
824
+ or (len(expected) >= 7 and to_sha.startswith(expected[:7]))
825
+ or (len(to_sha) >= 7 and expected.startswith(to_sha[:7]))
826
+ )
827
+ integrity["verified"] = ok
828
+ except Exception as exc:
829
+ integrity["error"] = f"{type(exc).__name__}: {exc}"
830
+
831
+ # If we stashed changes earlier, try to reapply them now.
832
+ stash_pop_conflict = False
833
+ if stash_ref:
834
+ pop = _run_git(project_dir, ["stash", "pop"], timeout=60)
835
+ if pop is None or pop.returncode != 0:
836
+ stash_pop_conflict = True
837
+
838
+ result = {
839
+ "success": True, "changed": changed,
840
+ "message": output or "update complete",
841
+ "from_version": from_version, "to_version": to_version,
842
+ "from_sha": from_sha, "to_sha": to_sha,
843
+ "stash_ref": stash_ref,
844
+ "stash_pop_conflict": stash_pop_conflict,
845
+ "integrity": integrity,
846
+ }
847
+ if stash_pop_conflict:
848
+ # Update applied successfully but the stash pop hit a conflict; the
849
+ # user's changes are still in the stash. Don't fail the overall update,
850
+ # but surface the conflict prominently.
851
+ result["reason"] = "stash_pop_conflict"
852
+ result["remediation"] = (
853
+ f"Update applied, but your stashed changes ({stash_ref}) conflict with the new build. "
854
+ "Resolve via `git stash list` + `git stash pop` after restarting; "
855
+ "use `git stash drop` if you want to discard them."
856
+ )
857
+
858
+ _record_attempt(project_dir, kind="update", success=True,
859
+ message=output or "update complete",
860
+ from_version=from_version, to_version=to_version,
861
+ from_sha=from_sha, to_sha=to_sha, initiator=initiator,
862
+ extra={"stash_ref": stash_ref,
863
+ "stash_pop_conflict": stash_pop_conflict,
864
+ "integrity": integrity})
865
+
866
+ return result
867
+
868
+
869
+ def rollback_to_previous_build(project_dir, *, initiator="cli"):
870
+ """git reset --hard to the from_sha of the most recent successful update.
871
+
872
+ Refuses if local changes exist (same guard as apply_safe_self_update) so we
873
+ never silently lose user work. Records a rollback row in update_history.
874
+ """
875
+ import json as _json
876
+ history_path = os.path.join(project_dir, "logs", "update_history.json")
877
+ if not os.path.isfile(history_path):
878
+ return {"success": False, "reason": "no_history", "message": "no update_history.json found"}
879
+ try:
880
+ with open(history_path, "r", encoding="utf-8") as fh:
881
+ history = _json.load(fh)
882
+ except (OSError, _json.JSONDecodeError) as exc:
883
+ return {"success": False, "reason": "history_read_failed", "message": str(exc)}
884
+
885
+ # Newest first; find the latest successful "update" entry with a from_sha.
886
+ candidates = [e for e in reversed(history.get("entries") or [])
887
+ if e.get("kind") == "update" and e.get("success") and e.get("from_sha")]
888
+ if not candidates:
889
+ return {"success": False, "reason": "no_target", "message": "no prior successful update to roll back to"}
890
+ target = candidates[0]
891
+ from_sha = target["from_sha"]
892
+
893
+ status = _run_git(project_dir, ["status", "--porcelain"])
894
+ if status is None or status.returncode != 0:
895
+ return {"success": False, "reason": "status_failed", "message": "could not inspect git status"}
896
+ if status.stdout.strip():
897
+ return {"success": False, "reason": "local_changes",
898
+ "message": "local changes are present; commit or stash before rolling back"}
899
+
900
+ head_before = _run_git(project_dir, ["rev-parse", "HEAD"])
901
+ pre_rollback_sha = head_before.stdout.strip() if head_before and head_before.returncode == 0 else None
902
+ pre_rollback_version = _read_current_version(project_dir)
903
+
904
+ reset = _run_git(project_dir, ["reset", "--hard", from_sha], timeout=60)
905
+ if reset is None or reset.returncode != 0:
906
+ msg = _git_failure_message(reset, f"git reset --hard {from_sha} failed")
907
+ _record_attempt(project_dir, kind="rollback", success=False, reason="reset_failed", message=msg,
908
+ from_version=pre_rollback_version, from_sha=pre_rollback_sha,
909
+ to_sha=from_sha, initiator=initiator)
910
+ return {"success": False, "reason": "reset_failed", "message": msg}
911
+
912
+ to_version = _read_current_version(project_dir)
913
+ _record_attempt(project_dir, kind="rollback", success=True,
914
+ message=f"rolled back to {from_sha[:10]}",
915
+ from_version=pre_rollback_version, to_version=to_version,
916
+ from_sha=pre_rollback_sha, to_sha=from_sha, initiator=initiator)
917
+ return {"success": True, "changed": pre_rollback_sha != from_sha,
918
+ "message": f"rolled back to {from_sha[:10]}",
919
+ "from_version": pre_rollback_version, "to_version": to_version,
920
+ "from_sha": pre_rollback_sha, "to_sha": from_sha}
921
+
922
+
923
+ def preview_update(project_dir):
924
+ """Fetch the target release metadata + scan for breaking-change markers.
925
+
926
+ Returns a dict the UI can render before the user confirms the update:
927
+ {success, latest_version, release_notes, breaking_changes, prerelease,
928
+ channel, current_version, target_sha}
929
+ """
930
+ try:
931
+ from src.utils.update_check import check_for_updates # type: ignore
932
+ except Exception as exc:
933
+ return {"success": False, "error": f"update_check unavailable: {exc}"}
934
+ current = _read_current_version(project_dir) or "0.0.0"
935
+ try:
936
+ result = check_for_updates(current, project_dir, force=True)
937
+ except Exception as exc:
938
+ return {"success": False, "error": str(exc)}
939
+ return {
940
+ "success": True,
941
+ "current_version": current,
942
+ "latest_version": result.get("latest_version"),
943
+ "release_notes": result.get("release_notes") or "",
944
+ "breaking_changes": result.get("release_notes_breaking") or [],
945
+ "prerelease": bool(result.get("prerelease")),
946
+ "channel": result.get("channel"),
947
+ "target_sha": result.get("release_target_sha"),
948
+ "release_url": result.get("release_url"),
949
+ "status": result.get("status"),
950
+ }
951
+
952
+
953
+ def read_update_history(project_dir, limit=20):
954
+ """Most-recent-first list of recorded update attempts. For the dashboard."""
955
+ import json as _json
956
+ path = os.path.join(project_dir, "logs", "update_history.json")
957
+ if not os.path.isfile(path):
958
+ return {"success": True, "entries": []}
959
+ try:
960
+ with open(path, "r", encoding="utf-8") as fh:
961
+ history = _json.load(fh)
962
+ except (OSError, _json.JSONDecodeError) as exc:
963
+ return {"success": False, "error": str(exc), "entries": []}
964
+ entries = (history.get("entries") or [])[-int(limit):]
965
+ entries.reverse()
966
+ return {"success": True, "entries": entries}
700
967
 
701
968
 
702
969
  def _restart_installer():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.24.1",
3
+ "version": "2.26.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",