forgexa-cli 1.21.2__tar.gz → 1.21.3__tar.gz
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.
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/PKG-INFO +1 -1
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/autoupgrade.py +1 -1
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/daemon.py +1 -1
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/main.py +103 -26
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/pyproject.toml +1 -1
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_auth_and_runtime_commands.py +1 -1
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_check_command.py +64 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_silent_install.py +170 -2
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_upgrade_observability.py +2 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/README.md +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/setup.cfg +0 -0
- {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_autoupgrade.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.21.
|
|
2
|
+
__version__ = "1.21.3"
|
|
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
555
555
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
556
556
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
557
557
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
558
|
-
DAEMON_VERSION = "1.21.
|
|
558
|
+
DAEMON_VERSION = "1.21.3"
|
|
559
559
|
|
|
560
560
|
|
|
561
561
|
def _detect_client_type() -> str:
|
|
@@ -557,6 +557,18 @@ def _upgrade_log_path() -> Path:
|
|
|
557
557
|
return Path.home() / ".forgexa" / "upgrade.log"
|
|
558
558
|
|
|
559
559
|
|
|
560
|
+
def _record_install_skip(target_version: str, reason: str) -> None:
|
|
561
|
+
"""Record why a silent install attempt was skipped, for `forgexa version --status`.
|
|
562
|
+
|
|
563
|
+
Covers the two "surprising" skip reasons (unsupported install source, a
|
|
564
|
+
running daemon) so `active`/`last_success`/`last_failure` all being "none"
|
|
565
|
+
isn't a silent mystery — see docs/designs/cli-auto-upgrade-design.md Appendix C.
|
|
566
|
+
"""
|
|
567
|
+
state = autoupgrade.read_state()
|
|
568
|
+
state["last_skip"] = {"version": target_version, "reason": reason, "at": time.time()}
|
|
569
|
+
autoupgrade.write_state(state)
|
|
570
|
+
|
|
571
|
+
|
|
560
572
|
def _maybe_install_update() -> None:
|
|
561
573
|
"""Attempt a single silent background install, if all safety conditions hold.
|
|
562
574
|
|
|
@@ -580,10 +592,14 @@ def _maybe_install_update() -> None:
|
|
|
580
592
|
try:
|
|
581
593
|
plan = _build_upgrade_plan(latest)
|
|
582
594
|
except RuntimeError:
|
|
583
|
-
|
|
595
|
+
# Unsupported/unsafe install source — leave it to the passive notice.
|
|
596
|
+
_record_install_skip(latest, "unsupported_source")
|
|
597
|
+
return
|
|
584
598
|
|
|
585
599
|
if _find_running_daemon_pid() is not None:
|
|
586
|
-
|
|
600
|
+
# Don't touch a running daemon; retry on a later invocation.
|
|
601
|
+
_record_install_skip(latest, "daemon_running")
|
|
602
|
+
return
|
|
587
603
|
|
|
588
604
|
state = autoupgrade.read_state()
|
|
589
605
|
target_version = latest
|
|
@@ -596,6 +612,7 @@ def _maybe_install_update() -> None:
|
|
|
596
612
|
|
|
597
613
|
try:
|
|
598
614
|
state["active"] = {"version": target_version, "started_at": time.time()}
|
|
615
|
+
state["last_skip"] = None
|
|
599
616
|
autoupgrade.write_state(state)
|
|
600
617
|
|
|
601
618
|
log_path = _upgrade_log_path()
|
|
@@ -639,14 +656,23 @@ def _maybe_install_update() -> None:
|
|
|
639
656
|
autoupgrade.release_lock()
|
|
640
657
|
|
|
641
658
|
|
|
642
|
-
def _run_update_check_and_maybe_install(allow_install: bool) -> None:
|
|
643
|
-
"""Refresh the update cache and, if allowed, attempt
|
|
659
|
+
def _run_update_check_and_maybe_install(allow_install: bool, refresh_cache: bool = True) -> None:
|
|
660
|
+
"""Refresh the update cache (if `refresh_cache`) and, if allowed, attempt
|
|
661
|
+
a silent background install.
|
|
644
662
|
|
|
645
663
|
`allow_install` reflects the parent process's interactive-session judgment
|
|
646
664
|
(TTY / CI / --format) at launch time — this worker's own stdio is redirected
|
|
647
665
|
to DEVNULL, so it can never determine that for itself.
|
|
666
|
+
|
|
667
|
+
`refresh_cache=False` supports retrying an install using the already-cached
|
|
668
|
+
latest version, without an extra PyPI round-trip — see
|
|
669
|
+
`_launch_update_check_process`'s "retry-only" path, which exists precisely
|
|
670
|
+
so an install attempt isn't stuck waiting for the network-check TTL to
|
|
671
|
+
expire just because *something else* (interactivity, a running daemon)
|
|
672
|
+
blocked the previous attempt.
|
|
648
673
|
"""
|
|
649
|
-
|
|
674
|
+
if refresh_cache:
|
|
675
|
+
_run_update_check()
|
|
650
676
|
if not allow_install:
|
|
651
677
|
return
|
|
652
678
|
try:
|
|
@@ -666,22 +692,56 @@ def _is_interactive_session() -> bool:
|
|
|
666
692
|
return bool(sys.stderr.isatty()) and not os.environ.get("CI") and _output_format != "json"
|
|
667
693
|
|
|
668
694
|
|
|
695
|
+
def _has_pending_silent_install(current_version: str) -> bool:
|
|
696
|
+
"""Whether a known newer version exists that hasn't been installed yet,
|
|
697
|
+
isn't already exhausted on failure backoff, and isn't already being
|
|
698
|
+
installed by a still-fresh `active` install right now.
|
|
699
|
+
|
|
700
|
+
Purely local (no network) — used so an install can be *retried* on a
|
|
701
|
+
later invocation even while the network-check cache is still fresh,
|
|
702
|
+
since whatever blocked the earlier attempt (a running daemon, a
|
|
703
|
+
momentarily non-interactive session) can resolve well within the
|
|
704
|
+
network check's 24h TTL, and there's no reason to make the user wait
|
|
705
|
+
that long for a retry.
|
|
706
|
+
"""
|
|
707
|
+
if not current_version:
|
|
708
|
+
return False
|
|
709
|
+
latest, _ = _read_update_cache()
|
|
710
|
+
if not latest or not _version_is_newer(latest, current_version):
|
|
711
|
+
return False
|
|
712
|
+
state = autoupgrade.read_state()
|
|
713
|
+
if autoupgrade.failure_attempts_for(state, latest) >= autoupgrade.FAILURE_BACKOFF_LIMIT:
|
|
714
|
+
return False
|
|
715
|
+
if autoupgrade.has_fresh_active_install(state, latest):
|
|
716
|
+
return False
|
|
717
|
+
return True
|
|
718
|
+
|
|
719
|
+
|
|
669
720
|
def _launch_update_check_process(allow_install: bool) -> None:
|
|
670
|
-
"""Start a detached worker process that refreshes the update cache and,
|
|
721
|
+
"""Start a detached worker process that refreshes the update cache and/or,
|
|
671
722
|
if `allow_install`, attempts a silent background install.
|
|
672
723
|
|
|
673
724
|
Short-lived commands like `forgexa version` can exit before a daemon thread
|
|
674
725
|
finishes its PyPI request. A separate Python process survives long enough to
|
|
675
726
|
populate the cache without blocking the foreground command.
|
|
676
727
|
|
|
728
|
+
The network check itself is rate-limited to once per `_UPDATE_CHECK_TTL` to
|
|
729
|
+
avoid hammering PyPI. Install *retries* are deliberately NOT tied to that
|
|
730
|
+
same window: when the cache is still fresh but a known newer version is
|
|
731
|
+
still pending (see `_has_pending_silent_install`), this launches a
|
|
732
|
+
network-free worker that just retries the local install steps.
|
|
733
|
+
|
|
677
734
|
The worker is launched fully detached (new session on POSIX, no console
|
|
678
735
|
window on Windows) so a Ctrl+C / signal aimed at the foreground command's
|
|
679
736
|
process group can't take an in-progress install down with it and leave a
|
|
680
737
|
half-installed package.
|
|
681
738
|
"""
|
|
682
739
|
_, checked_at = _read_update_cache()
|
|
683
|
-
|
|
684
|
-
|
|
740
|
+
cache_stale = time.time() - checked_at > _UPDATE_CHECK_TTL
|
|
741
|
+
|
|
742
|
+
if not cache_stale:
|
|
743
|
+
if not allow_install or not _has_pending_silent_install(_installed_cli_version() or ""):
|
|
744
|
+
return # Cache is fresh and there's nothing pending to retry.
|
|
685
745
|
|
|
686
746
|
popen_kwargs: dict = {}
|
|
687
747
|
if sys.platform == "win32":
|
|
@@ -697,7 +757,7 @@ def _launch_update_check_process(allow_install: bool) -> None:
|
|
|
697
757
|
sys.executable,
|
|
698
758
|
"-c",
|
|
699
759
|
"from forgexa_cli.main import _run_update_check_and_maybe_install; "
|
|
700
|
-
f"_run_update_check_and_maybe_install({bool(allow_install)!r})",
|
|
760
|
+
f"_run_update_check_and_maybe_install({bool(allow_install)!r}, {bool(cache_stale)!r})",
|
|
701
761
|
],
|
|
702
762
|
stdin=subprocess.DEVNULL,
|
|
703
763
|
stdout=subprocess.DEVNULL,
|
|
@@ -1683,11 +1743,15 @@ def _print_check_report(results: list[dict], quick: bool) -> None:
|
|
|
1683
1743
|
print("Gemini CLI, Kimi Code, or GitHub Copilot CLI), then re-run: forgexa check")
|
|
1684
1744
|
|
|
1685
1745
|
|
|
1686
|
-
def _run_environment_checks(narrate: bool) -> bool:
|
|
1746
|
+
def _run_environment_checks(narrate: bool) -> tuple[bool, list[dict]]:
|
|
1687
1747
|
"""Run environment checks (git, workspace, disk) using the daemon's check logic.
|
|
1688
1748
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1749
|
+
Always runs regardless of --quick/--environment/default mode: these checks
|
|
1750
|
+
are free (no network, no LLM tokens), so there is no cost reason to hide
|
|
1751
|
+
them behind a flag.
|
|
1752
|
+
|
|
1753
|
+
Returns (blocking_failed, checks). Prints a flutter-doctor-style listing
|
|
1754
|
+
in table mode.
|
|
1691
1755
|
"""
|
|
1692
1756
|
import asyncio
|
|
1693
1757
|
from forgexa_cli.daemon import RuntimePreflightMonitor, settings
|
|
@@ -1705,7 +1769,7 @@ def _run_environment_checks(narrate: bool) -> bool:
|
|
|
1705
1769
|
except Exception as exc:
|
|
1706
1770
|
if narrate:
|
|
1707
1771
|
print(f" [ERROR] Environment check failed: {exc}")
|
|
1708
|
-
return True
|
|
1772
|
+
return True, []
|
|
1709
1773
|
|
|
1710
1774
|
blocking_failed = False
|
|
1711
1775
|
for check in checks:
|
|
@@ -1739,7 +1803,7 @@ def _run_environment_checks(narrate: bool) -> bool:
|
|
|
1739
1803
|
print("All environment checks passed.")
|
|
1740
1804
|
print()
|
|
1741
1805
|
|
|
1742
|
-
return blocking_failed
|
|
1806
|
+
return blocking_failed, checks
|
|
1743
1807
|
|
|
1744
1808
|
|
|
1745
1809
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
@@ -1761,19 +1825,23 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1761
1825
|
timeout = getattr(args, "timeout", None) or 90
|
|
1762
1826
|
|
|
1763
1827
|
narrate = _fmt() == "table"
|
|
1764
|
-
env_failed = False
|
|
1765
1828
|
|
|
1766
1829
|
# ── Environment checks (shared with daemon's RuntimePreflightMonitor) ──
|
|
1767
|
-
#
|
|
1768
|
-
# no network, no LLM tokens) and reuse the exact same
|
|
1769
|
-
# daemon uses for its periodic preflight
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1830
|
+
# Always run, in every mode (default/--quick/--environment): these are
|
|
1831
|
+
# free (no API calls, no network, no LLM tokens) and reuse the exact same
|
|
1832
|
+
# check logic the daemon uses for its periodic preflight, so there is no
|
|
1833
|
+
# cost reason to hide them behind a flag.
|
|
1834
|
+
env_failed, env_checks = _run_environment_checks(narrate)
|
|
1835
|
+
if environment_only:
|
|
1836
|
+
fmt = _fmt()
|
|
1837
|
+
if fmt == "json":
|
|
1838
|
+
print(json.dumps(env_checks, indent=2, ensure_ascii=False))
|
|
1839
|
+
elif fmt == "quiet":
|
|
1840
|
+
for c in env_checks:
|
|
1841
|
+
print(f"{c.get('id', '?')}\t{c.get('status', 'unknown')}")
|
|
1842
|
+
if env_failed:
|
|
1843
|
+
sys.exit(1)
|
|
1844
|
+
return
|
|
1777
1845
|
|
|
1778
1846
|
known_ids = list(AgentDiscovery.AGENT_REGISTRY.keys())
|
|
1779
1847
|
if only_agent:
|
|
@@ -2023,6 +2091,15 @@ def _print_auto_upgrade_status() -> None:
|
|
|
2023
2091
|
else:
|
|
2024
2092
|
print("Last failure : none")
|
|
2025
2093
|
|
|
2094
|
+
skip = state.get("last_skip")
|
|
2095
|
+
if skip:
|
|
2096
|
+
print(
|
|
2097
|
+
f"Last skip : {skip.get('version')} "
|
|
2098
|
+
f"(reason={skip.get('reason')}, at {_format_epoch(skip.get('at'))})"
|
|
2099
|
+
)
|
|
2100
|
+
else:
|
|
2101
|
+
print("Last skip : none")
|
|
2102
|
+
|
|
2026
2103
|
print(f"Upgrade log : {_upgrade_log_path()}")
|
|
2027
2104
|
|
|
2028
2105
|
|
|
@@ -2277,7 +2354,7 @@ def main() -> None:
|
|
|
2277
2354
|
check_p.add_argument("--agent", default=None, help="Check only this agent (e.g. claude, codex, kimi)")
|
|
2278
2355
|
check_p.add_argument(
|
|
2279
2356
|
"--quick", action="store_true",
|
|
2280
|
-
help="
|
|
2357
|
+
help="Skip the live task execution test (agent discovery + environment checks only, no API calls)",
|
|
2281
2358
|
)
|
|
2282
2359
|
check_p.add_argument(
|
|
2283
2360
|
"--environment", action="store_true",
|
|
@@ -325,7 +325,7 @@ def test_launch_update_check_process_spawns_background_python_when_cache_is_stal
|
|
|
325
325
|
assert cmd[:2] == ["/usr/bin/python3", "-c"]
|
|
326
326
|
assert cmd[2] == (
|
|
327
327
|
"from forgexa_cli.main import _run_update_check_and_maybe_install; "
|
|
328
|
-
"_run_update_check_and_maybe_install(True)"
|
|
328
|
+
"_run_update_check_and_maybe_install(True, True)"
|
|
329
329
|
)
|
|
330
330
|
assert kwargs["stdin"] is main.subprocess.DEVNULL
|
|
331
331
|
assert kwargs["stdout"] is main.subprocess.DEVNULL
|
|
@@ -140,3 +140,67 @@ def test_check_json_output_is_parseable(
|
|
|
140
140
|
"available": True,
|
|
141
141
|
"reason": "",
|
|
142
142
|
}]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _mock_environment_checks(monkeypatch: pytest.MonkeyPatch, checks: list[dict]) -> None:
|
|
146
|
+
monkeypatch.setattr(
|
|
147
|
+
daemon.RuntimePreflightMonitor, "_run_checks",
|
|
148
|
+
AsyncMock(return_value=checks),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_check_default_mode_also_shows_environment_checks(
|
|
153
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Environment checks (git/workspace/disk) are free (no network, no LLM
|
|
156
|
+
tokens) and must be visible by default, not just behind --quick/--environment.
|
|
157
|
+
"""
|
|
158
|
+
monkeypatch.setattr(daemon.AgentDiscovery, "AGENT_REGISTRY", _small_registry())
|
|
159
|
+
monkeypatch.setattr(
|
|
160
|
+
daemon.AgentDiscovery, "discover",
|
|
161
|
+
AsyncMock(return_value=[_agent("claude")]),
|
|
162
|
+
)
|
|
163
|
+
monkeypatch.setattr(
|
|
164
|
+
daemon.ProcessManager, "run_agent",
|
|
165
|
+
AsyncMock(return_value=daemon.TaskResult(status="success", exit_code=0, stdout="OK", stderr="")),
|
|
166
|
+
)
|
|
167
|
+
_mock_environment_checks(monkeypatch, [
|
|
168
|
+
{"id": "git_executable", "status": "pass", "blocking": True, "detail": "git 2.50", "remediation": ""},
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
main.cmd_check(SimpleNamespace(agent="claude", quick=False, environment=False, timeout=5))
|
|
172
|
+
|
|
173
|
+
out = capsys.readouterr().out
|
|
174
|
+
assert "Environment Checks" in out
|
|
175
|
+
assert "git_executable" in out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_check_environment_only_json_output_lists_checks(
|
|
179
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
|
180
|
+
) -> None:
|
|
181
|
+
"""--environment previously printed nothing at all under --format json;
|
|
182
|
+
it must return the structured checks array like every other subcommand."""
|
|
183
|
+
monkeypatch.setattr(main, "_output_format", "json")
|
|
184
|
+
_mock_environment_checks(monkeypatch, [
|
|
185
|
+
{"id": "git_executable", "status": "pass", "blocking": True, "detail": "git 2.50", "remediation": ""},
|
|
186
|
+
{"id": "workspace_root", "status": "pass", "blocking": True, "detail": "/tmp/ws", "remediation": ""},
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
main.cmd_check(SimpleNamespace(agent=None, quick=False, environment=True, timeout=90))
|
|
190
|
+
|
|
191
|
+
out = capsys.readouterr().out
|
|
192
|
+
payload = json.loads(out)
|
|
193
|
+
assert [c["id"] for c in payload] == ["git_executable", "workspace_root"]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_check_environment_only_exits_nonzero_on_blocking_failure(
|
|
197
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
|
198
|
+
) -> None:
|
|
199
|
+
_mock_environment_checks(monkeypatch, [
|
|
200
|
+
{"id": "git_executable", "status": "fail", "blocking": True, "detail": "not found", "remediation": "install git"},
|
|
201
|
+
])
|
|
202
|
+
|
|
203
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
204
|
+
main.cmd_check(SimpleNamespace(agent=None, quick=False, environment=True, timeout=90))
|
|
205
|
+
|
|
206
|
+
assert exc_info.value.code == 1
|
|
@@ -60,6 +60,9 @@ def test_skips_when_upgrade_plan_unsupported(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
60
60
|
main._maybe_install_update() # must not raise
|
|
61
61
|
|
|
62
62
|
assert run_mock.called is False
|
|
63
|
+
state = autoupgrade.read_state()
|
|
64
|
+
assert state["last_skip"]["reason"] == "unsupported_source"
|
|
65
|
+
assert state["last_skip"]["version"] == "1.1.0"
|
|
63
66
|
|
|
64
67
|
|
|
65
68
|
def test_skips_when_daemon_is_running(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -73,8 +76,14 @@ def test_skips_when_daemon_is_running(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
73
76
|
main._maybe_install_update()
|
|
74
77
|
|
|
75
78
|
assert run_mock.called is False
|
|
76
|
-
# Must not touch the daemon or leave any install-in-progress state behind
|
|
77
|
-
|
|
79
|
+
# Must not touch the daemon or leave any install-in-progress state behind,
|
|
80
|
+
# but the skip reason IS recorded (see docs/designs/cli-auto-upgrade-design.md
|
|
81
|
+
# Appendix C) so `forgexa version --status` can explain what happened.
|
|
82
|
+
state = autoupgrade.read_state()
|
|
83
|
+
assert state["active"] is None
|
|
84
|
+
assert state["last_success"] is None
|
|
85
|
+
assert state["last_failure"] is None
|
|
86
|
+
assert state["last_skip"] == {"version": "1.1.0", "reason": "daemon_running", "at": state["last_skip"]["at"]}
|
|
78
87
|
|
|
79
88
|
|
|
80
89
|
def test_skips_when_failure_backoff_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -156,6 +165,27 @@ def test_success_writes_last_success_and_releases_lock(monkeypatch: pytest.Monke
|
|
|
156
165
|
assert (Path.home() / ".forgexa" / "upgrade.lock").exists() is False
|
|
157
166
|
|
|
158
167
|
|
|
168
|
+
def test_success_clears_a_previously_recorded_skip(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
169
|
+
"""A stale 'skipped: daemon running' note shouldn't linger once an install actually runs."""
|
|
170
|
+
_versions(monkeypatch, ["1.0.0", "1.1.0"])
|
|
171
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
|
|
172
|
+
monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: _fake_plan(target))
|
|
173
|
+
_no_daemon(monkeypatch)
|
|
174
|
+
monkeypatch.setattr(main.subprocess, "run", lambda cmd, **k: SimpleNamespace(returncode=0))
|
|
175
|
+
autoupgrade.write_state(
|
|
176
|
+
{
|
|
177
|
+
"active": None,
|
|
178
|
+
"last_failure": None,
|
|
179
|
+
"last_success": None,
|
|
180
|
+
"last_skip": {"version": "1.1.0", "reason": "daemon_running", "at": 0},
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
main._maybe_install_update()
|
|
185
|
+
|
|
186
|
+
assert autoupgrade.read_state()["last_skip"] is None
|
|
187
|
+
|
|
188
|
+
|
|
159
189
|
def test_returncode_zero_but_wrong_version_counts_as_failure(
|
|
160
190
|
monkeypatch: pytest.MonkeyPatch,
|
|
161
191
|
) -> None:
|
|
@@ -247,6 +277,20 @@ def test_run_update_check_and_maybe_install_swallows_unexpected_errors(
|
|
|
247
277
|
main._run_update_check_and_maybe_install(True) # must not raise
|
|
248
278
|
|
|
249
279
|
|
|
280
|
+
def test_run_update_check_and_maybe_install_skips_network_refresh_when_retrying(
|
|
281
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
282
|
+
) -> None:
|
|
283
|
+
refresh_called = SimpleNamespace(value=False)
|
|
284
|
+
monkeypatch.setattr(main, "_run_update_check", lambda: refresh_called.__setattr__("value", True))
|
|
285
|
+
install_called = SimpleNamespace(value=False)
|
|
286
|
+
monkeypatch.setattr(main, "_maybe_install_update", lambda: install_called.__setattr__("value", True))
|
|
287
|
+
|
|
288
|
+
main._run_update_check_and_maybe_install(True, refresh_cache=False)
|
|
289
|
+
|
|
290
|
+
assert refresh_called.value is False
|
|
291
|
+
assert install_called.value is True
|
|
292
|
+
|
|
293
|
+
|
|
250
294
|
# ── _is_interactive_session ─────────────────────────────────────────────────
|
|
251
295
|
|
|
252
296
|
def test_is_interactive_session_true_when_tty_and_no_ci_and_not_json(
|
|
@@ -330,3 +374,127 @@ def test_main_skips_worker_entirely_when_auto_upgrade_off(
|
|
|
330
374
|
|
|
331
375
|
assert launched.value is False
|
|
332
376
|
assert notified.value is False
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ── _has_pending_silent_install ─────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
def test_pending_install_true_when_newer_cached_and_not_backed_off(
|
|
382
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
383
|
+
) -> None:
|
|
384
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
|
|
385
|
+
assert main._has_pending_silent_install("1.0.0") is True
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_pending_install_false_when_no_current_version() -> None:
|
|
389
|
+
assert main._has_pending_silent_install("") is False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_pending_install_false_when_cache_has_no_newer_version(
|
|
393
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
394
|
+
) -> None:
|
|
395
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.0.0", 0.0))
|
|
396
|
+
assert main._has_pending_silent_install("1.0.0") is False
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_pending_install_false_when_backoff_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
400
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
|
|
401
|
+
autoupgrade.write_state(
|
|
402
|
+
{
|
|
403
|
+
"active": None,
|
|
404
|
+
"last_failure": {"version": "1.1.0", "failed_at": 0, "attempts": 2},
|
|
405
|
+
"last_success": None,
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
assert main._has_pending_silent_install("1.0.0") is False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_pending_install_false_when_already_actively_installing(
|
|
412
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
413
|
+
) -> None:
|
|
414
|
+
import time as time_module
|
|
415
|
+
|
|
416
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
|
|
417
|
+
autoupgrade.write_state(
|
|
418
|
+
{
|
|
419
|
+
"active": {"version": "1.1.0", "started_at": time_module.time()},
|
|
420
|
+
"last_failure": None,
|
|
421
|
+
"last_success": None,
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
assert main._has_pending_silent_install("1.0.0") is False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ── _launch_update_check_process: retry an install even when the cache is fresh ──
|
|
428
|
+
#
|
|
429
|
+
# Regression coverage for a real bug found via manual testing (2026-07-23):
|
|
430
|
+
# the install retry was tied to the same 24h TTL as the network check, so once
|
|
431
|
+
# an install attempt was blocked for any reason (daemon running, a momentarily
|
|
432
|
+
# non-interactive session, ...), the CLI would keep showing the same cached
|
|
433
|
+
# "update available" notice without ever retrying the install until the cache
|
|
434
|
+
# went stale up to 24h later.
|
|
435
|
+
|
|
436
|
+
def test_launch_process_retries_install_when_cache_fresh_and_install_pending(
|
|
437
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
438
|
+
) -> None:
|
|
439
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", main.time.time()))
|
|
440
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0")
|
|
441
|
+
monkeypatch.setattr(main.sys, "executable", "/usr/bin/python3")
|
|
442
|
+
|
|
443
|
+
calls: list[tuple[list[str], dict]] = []
|
|
444
|
+
monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
|
|
445
|
+
|
|
446
|
+
main._launch_update_check_process(True)
|
|
447
|
+
|
|
448
|
+
assert len(calls) == 1
|
|
449
|
+
cmd, _kwargs = calls[0]
|
|
450
|
+
# refresh_cache must be False: retry the install without hitting PyPI again.
|
|
451
|
+
assert cmd[2] == (
|
|
452
|
+
"from forgexa_cli.main import _run_update_check_and_maybe_install; "
|
|
453
|
+
"_run_update_check_and_maybe_install(True, False)"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_launch_process_does_nothing_when_cache_fresh_and_not_allowed_to_install(
|
|
458
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
459
|
+
) -> None:
|
|
460
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", main.time.time()))
|
|
461
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0")
|
|
462
|
+
calls: list[tuple[list[str], dict]] = []
|
|
463
|
+
monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
|
|
464
|
+
|
|
465
|
+
main._launch_update_check_process(False) # e.g. notify mode
|
|
466
|
+
|
|
467
|
+
assert calls == []
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_launch_process_does_nothing_when_cache_fresh_and_nothing_pending(
|
|
471
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
472
|
+
) -> None:
|
|
473
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.0.0", main.time.time()))
|
|
474
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0") # already up to date
|
|
475
|
+
calls: list[tuple[list[str], dict]] = []
|
|
476
|
+
monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
|
|
477
|
+
|
|
478
|
+
main._launch_update_check_process(True)
|
|
479
|
+
|
|
480
|
+
assert calls == []
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_launch_process_does_not_retry_once_backoff_exhausted(
|
|
484
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
485
|
+
) -> None:
|
|
486
|
+
monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", main.time.time()))
|
|
487
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0")
|
|
488
|
+
autoupgrade.write_state(
|
|
489
|
+
{
|
|
490
|
+
"active": None,
|
|
491
|
+
"last_failure": {"version": "1.1.0", "failed_at": 0, "attempts": 2},
|
|
492
|
+
"last_success": None,
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
calls: list[tuple[list[str], dict]] = []
|
|
496
|
+
monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
|
|
497
|
+
|
|
498
|
+
main._launch_update_check_process(True)
|
|
499
|
+
|
|
500
|
+
assert calls == []
|
|
@@ -116,6 +116,7 @@ def test_version_status_shows_full_state(
|
|
|
116
116
|
"active": {"version": "1.22.0", "started_at": 0},
|
|
117
117
|
"last_failure": {"version": "1.21.0", "failed_at": 0, "attempts": 1},
|
|
118
118
|
"last_success": {"version": "1.20.0", "installed_at": 0, "notified": True},
|
|
119
|
+
"last_skip": {"version": "1.22.0", "reason": "daemon_running", "at": 0},
|
|
119
120
|
}
|
|
120
121
|
)
|
|
121
122
|
|
|
@@ -126,4 +127,5 @@ def test_version_status_shows_full_state(
|
|
|
126
127
|
assert "Active install : 1.22.0" in out
|
|
127
128
|
assert "Last success : 1.20.0" in out
|
|
128
129
|
assert "Last failure : 1.21.0" in out
|
|
130
|
+
assert "Last skip : 1.22.0 (reason=daemon_running" in out
|
|
129
131
|
assert "Upgrade log :" in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|