feat(#92): detect unpinned npx specs, resolve version, one-click pin, drift
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 13s
CI / Catalog signature (pull_request) Successful in 9s
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 13s
CI / Catalog signature (pull_request) Successful in 9s
`npx -y ssh-mcp` resolves *latest* on every launch — in one session ssh-mcp went
v1 → v2 and the tool set changed under a running agent, mid-task, with no warning.
This detects that and turns it into a comprehensible pin/upgrade prompt.
Core (pure, no network — reuses parse_version / is_newer_version / the catalog
spec parsers; the resolved-version lookup is fully injectable for tests):
- server_package_spec / server_package_name — the npm spec an npx-style server runs.
- is_unpinned_spec — bare name or dist-tag (@latest/@next) is unpinned; an exact
numeric version is pinned.
- resolved_npx_version — reads the local ~/.npm/_npx cache (highest version wins),
degrades to None cleanly. NO network. find/read injectable.
- pin_spec_transform — rewrite the spec to name@version (mirrors pin_command_path's
(new_data, note) contract). No-op when already pinned / bad version / not npx.
- version_drift_note — "moved X → Y since you pinned" via numeric is_newer_version.
- version_status — the badge's high-level dict (unpinned / pinned_version /
resolved_version / can_pin / drift). A _UNSET sentinel lets callers force an
explicit resolved=None ("unknown") vs. omitting it to do the local lookup.
GUI: a version badge row under the dependency status (mirrors that surface) with a
one-click "Pin to <version>" button, shown only for npx servers; a drift note when
a pinned version has been overtaken. Smoke-tested headlessly.
pytest green (520 passed), ruff + format clean. Closes #92. Part of epic #94.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e087107710
commit
2cd8e0fb3b
@@ -819,6 +819,27 @@ class ServerEditor(QFrame):
|
||||
dep.addWidget(recheck)
|
||||
outer.addLayout(dep)
|
||||
|
||||
# Version status row (#92): for an npx-style server, show the currently
|
||||
# resolved version and, when the spec is unpinned, a one-click "pin".
|
||||
# Mirrors the dependency-status surface above. Hidden for everything else.
|
||||
ver = QHBoxLayout()
|
||||
self.ver_dot = QLabel("○")
|
||||
self.ver_label = QLabel("—")
|
||||
self.ver_label.setObjectName("muted")
|
||||
self.ver_label.setWordWrap(True)
|
||||
self.pin_btn = QPushButton("Pin")
|
||||
self.pin_btn.setToolTip(
|
||||
"Pin the package spec to the currently-resolved version so it can't "
|
||||
"change under you on the next launch"
|
||||
)
|
||||
self.pin_btn.clicked.connect(self._pin_version)
|
||||
self.pin_btn.setVisible(False)
|
||||
ver.addWidget(self.ver_dot)
|
||||
ver.addWidget(self.ver_label, 1)
|
||||
ver.addWidget(self.pin_btn)
|
||||
self.ver_row_widgets = (self.ver_dot, self.ver_label, self.pin_btn)
|
||||
outer.addLayout(ver)
|
||||
|
||||
# Collapsible diagnostics panel
|
||||
self.diag_card = QFrame()
|
||||
self.diag_card.setObjectName("diagCard")
|
||||
@@ -1146,6 +1167,8 @@ class ServerEditor(QFrame):
|
||||
if not self.isEnabled():
|
||||
self._set_dep({"status": "unknown", "label": "—"})
|
||||
self.diag_text.clear()
|
||||
for wdg in self.ver_row_widgets:
|
||||
wdg.hide()
|
||||
return
|
||||
data = self.dump_data()
|
||||
res = core.check_dependency(data)
|
||||
@@ -1159,6 +1182,49 @@ class ServerEditor(QFrame):
|
||||
self.details_btn.setChecked(True) # opens panel (fills text via _toggle_diag)
|
||||
if self.diag_card.isVisible():
|
||||
self.diag_text.setPlainText(self._full_diag_text())
|
||||
self._refresh_version_badge(data)
|
||||
|
||||
def _refresh_version_badge(self, data: dict):
|
||||
"""Show the resolved version / pin state for an npx-style server (#92)."""
|
||||
st = core.version_status(data) if self.type.currentIndex() == 0 else None
|
||||
if st is None:
|
||||
for wdg in self.ver_row_widgets:
|
||||
wdg.hide()
|
||||
return
|
||||
for wdg in self.ver_row_widgets:
|
||||
wdg.show()
|
||||
resolved = st["resolved_version"] or "unknown"
|
||||
if st["drift"]:
|
||||
glyph, color, text = "●", WARN, f"{st['package']} {st['drift']}"
|
||||
elif st["unpinned"]:
|
||||
glyph, color = "●", WARN
|
||||
text = f"{st['package']} · resolved {resolved} · unpinned (resolves latest each launch)"
|
||||
else:
|
||||
glyph, color = "●", GOOD
|
||||
text = f"{st['package']} · pinned {st['pinned_version']}"
|
||||
self.ver_dot.setText(glyph)
|
||||
self.ver_dot.setStyleSheet(f"color: {color}; font-size: 14px;")
|
||||
self.ver_label.setText(text)
|
||||
self.ver_label.setStyleSheet(f"color: {color};")
|
||||
# Offer the pin only when unpinned AND we know what to pin to.
|
||||
self.pin_btn.setVisible(st["can_pin"])
|
||||
if st["can_pin"]:
|
||||
self.pin_btn.setText(f"Pin to {resolved}")
|
||||
self._version_resolved = st["resolved_version"]
|
||||
|
||||
def _pin_version(self):
|
||||
if self._before_change:
|
||||
self._before_change()
|
||||
resolved = getattr(self, "_version_resolved", None)
|
||||
if not resolved:
|
||||
return
|
||||
new_data, note = core.pin_spec_transform(self.dump_data(), resolved)
|
||||
if not note:
|
||||
return
|
||||
self._loading = True
|
||||
self.args.setPlainText("\n".join(str(a) for a in (new_data.get("args") or [])))
|
||||
self._loading = False
|
||||
self._emit() # writes back to the model and re-checks (badge now "pinned")
|
||||
|
||||
def _set_dep(self, res: dict):
|
||||
status = res.get("status", "unknown")
|
||||
|
||||
Reference in New Issue
Block a user