forgexa-cli 1.14.6__tar.gz → 1.14.8__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.6
3
+ Version: 1.14.8
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -26,8 +26,10 @@ Classifier: Topic :: Software Development :: Quality Assurance
26
26
  Requires-Python: >=3.9
27
27
  Description-Content-Type: text/markdown
28
28
  Requires-Dist: httpx>=0.24
29
+ Requires-Dist: socksio>=1.0.0
29
30
  Provides-Extra: daemon
30
31
  Requires-Dist: httpx>=0.24; extra == "daemon"
32
+ Requires-Dist: socksio>=1.0.0; extra == "daemon"
31
33
 
32
34
  # Forgexa CLI
33
35
 
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.14.6"
2
+ __version__ = "1.14.8"
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
523
523
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
524
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
525
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.14.6"
526
+ DAEMON_VERSION = "1.14.8"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -1711,17 +1711,27 @@ class WorkspaceManager:
1711
1711
 
1712
1712
  @staticmethod
1713
1713
  def _safe_rmtree(path: Path) -> None:
1714
- """Remove a directory tree, handling read-only files on Windows.
1715
-
1716
- On Windows, git object files (.git/objects/**/*) are stored read-only
1717
- (mode 0444). Plain shutil.rmtree(path, ignore_errors=True) silently
1718
- skips those files, leaving the directory intact. A subsequent
1719
- git clone then fails with 'destination path already exists and is
1720
- not an empty directory'.
1721
-
1722
- This method uses the onerror callback to chmod each entry writable
1723
- before retrying the established pattern for handling git repos on
1724
- Windows.
1714
+ """Remove a directory tree, handling two distinct Windows failure modes.
1715
+
1716
+ Mode 1 Read-only git objects (chmod 0444):
1717
+ Plain shutil.rmtree(path, ignore_errors=True) silently skips
1718
+ read-only files. Fixed by the onerror callback that chmod’s each
1719
+ entry writable before retrying.
1720
+
1721
+ Mode 2 — Windows reserved device names (NUL, CON, AUX, PRN,
1722
+ COM1–COM9, LPT1–LPT9):
1723
+ The Win32 API intercepts these names before they reach the NTFS
1724
+ driver, so os.unlink / shutil.rmtree / PowerShell Remove-Item all
1725
+ fail with a permissions or access error. The solution is the
1726
+ \\?\ extended-length path prefix, which instructs Windows to
1727
+ skip Win32 name parsing and pass the path directly to the NT
1728
+ kernel file-system layer. 'cmd /c rmdir /s /q' reliably handles
1729
+ this when given a \\?\ prefixed path.
1730
+
1731
+ Deletion strategy (each stage only runs if the path still exists):
1732
+ 1. shutil.rmtree with onerror chmod — handles read-only objects.
1733
+ 2. Windows only: subprocess cmd /c rmdir /s /q \\\\?\\<path> —
1734
+ handles reserved names and any entries missed by stage 1.
1725
1735
  """
1726
1736
  import shutil
1727
1737
  import stat as _stat
@@ -1738,6 +1748,39 @@ class WorkspaceManager:
1738
1748
 
1739
1749
  shutil.rmtree(path, onerror=_handle_readonly)
1740
1750
 
1751
+ # Windows fallback: reserved device names survive shutil.rmtree.
1752
+ # cmd /c rmdir /s /q with the \\?\ extended-length prefix bypasses
1753
+ # Win32 name interception and reaches the NTFS layer directly.
1754
+ if path.exists() and sys.platform == "win32":
1755
+ try:
1756
+ import subprocess
1757
+ resolved = str(path.resolve())
1758
+ # Prepend \\?\ extended-length prefix if not already present.
1759
+ # In Python source "\\\\?\\" represents the 4-char string \\?\
1760
+ # which is the Win32 flag to bypass reserved-name interception.
1761
+ if not resolved.startswith("\\\\?\\"):
1762
+ unc = "\\\\?\\" + resolved
1763
+ else:
1764
+ unc = resolved
1765
+ result = subprocess.run(
1766
+ ["cmd", "/c", "rmdir", "/s", "/q", unc],
1767
+ capture_output=True,
1768
+ timeout=60,
1769
+ )
1770
+ if result.returncode != 0 and path.exists():
1771
+ logger.warning(
1772
+ "_safe_rmtree: cmd rmdir could not fully remove %s "
1773
+ "(exit %d: %s). Manual deletion may be required.",
1774
+ path,
1775
+ result.returncode,
1776
+ result.stderr.decode(errors="replace").strip()[:200],
1777
+ )
1778
+ except Exception as exc:
1779
+ logger.warning(
1780
+ "_safe_rmtree: Windows rmdir fallback failed for %s: %s",
1781
+ path, exc,
1782
+ )
1783
+
1741
1784
  async def _clone_repo(
1742
1785
  self,
1743
1786
  repo_url: str,
@@ -4616,6 +4659,18 @@ class ProcessManager:
4616
4659
  *cmd,
4617
4660
  stdout=asyncio.subprocess.PIPE,
4618
4661
  stderr=asyncio.subprocess.PIPE,
4662
+ # stdin=DEVNULL is critical on Windows.
4663
+ # Without it, the child process inherits the parent daemon's stdin.
4664
+ # On Windows, the daemon often has a real console handle on stdin
4665
+ # (e.g. from a CMD/PowerShell window or the Desktop app's console).
4666
+ # Copilot CLI detects this via GetConsoleMode(GetStdHandle(STD_INPUT))
4667
+ # and enters interactive/session-check mode, ignoring -p as a task:
4668
+ # it checks the session todo table (empty), reports "no tracked work
4669
+ # items", and waits for user input instead of executing the task.
4670
+ # On Linux/macOS (daemon as service), stdin is typically /dev/null so
4671
+ # the bug doesn't manifest. Explicit DEVNULL ensures non-interactive
4672
+ # mode on all platforms, matching the intended headless operation.
4673
+ stdin=asyncio.subprocess.DEVNULL,
4619
4674
  cwd=str(cwd),
4620
4675
  env=env,
4621
4676
  limit=100 * 1024 * 1024,
@@ -162,21 +162,14 @@ def _refresh_access_token() -> bool:
162
162
  if not refresh_token:
163
163
  return False
164
164
 
165
- import urllib.request
166
- import urllib.error
165
+ import httpx
167
166
 
168
167
  url = f"{_api_url()}/api/v1/auth/refresh"
169
- body = json.dumps({"refresh_token": refresh_token}).encode()
170
- req = urllib.request.Request(
171
- url,
172
- data=body,
173
- headers={"Content-Type": "application/json"},
174
- method="POST",
175
- )
176
168
  try:
177
- with urllib.request.urlopen(req, timeout=15) as resp:
178
- payload = json.loads(resp.read())
179
- except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, OSError):
169
+ with httpx.Client(timeout=15) as client:
170
+ resp = client.post(url, json={"refresh_token": refresh_token})
171
+ payload = resp.json()
172
+ except Exception:
180
173
  return False
181
174
 
182
175
  access_token = str(payload.get("access_token") or "").strip()
@@ -412,6 +405,117 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
412
405
  )
413
406
 
414
407
 
408
+ # ── Update-check helpers ──────────────────────────────────────────────────────
409
+ #
410
+ # Strategy (borrowed from gh CLI / update-notifier):
411
+ # 1. At the start of main(): launch a daemon thread that calls PyPI and
412
+ # writes ~/.forgexa/update-check.json (only when the cache is stale).
413
+ # 2. At the end of main(): read the cache (instant) and, if a newer version
414
+ # is available, print one notice to stderr.
415
+ #
416
+ # Zero latency on the critical path — we never wait for the network.
417
+ # Auto-skipped in CI, non-TTY stderr, `forgexa upgrade`, and --format json.
418
+
419
+ _UPDATE_CACHE_PATH: Path = Path.home() / ".forgexa" / "update-check.json"
420
+ _UPDATE_CHECK_TTL: int = 24 * 3600 # seconds
421
+
422
+
423
+ def _read_update_cache() -> tuple[str | None, float]:
424
+ """Return (latest_version, checked_at_epoch). Never raises."""
425
+ try:
426
+ data = json.loads(_UPDATE_CACHE_PATH.read_text())
427
+ version = str(data.get("latest_version") or "").strip()
428
+ checked_at = float(data.get("checked_at") or 0)
429
+ return (version or None, checked_at)
430
+ except Exception:
431
+ return (None, 0.0)
432
+
433
+
434
+ def _write_update_cache(latest_version: str) -> None:
435
+ """Atomically write the update cache. Never raises."""
436
+ try:
437
+ _UPDATE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
438
+ tmp = _UPDATE_CACHE_PATH.with_suffix(".json.tmp")
439
+ tmp.write_text(
440
+ json.dumps({"latest_version": latest_version, "checked_at": time.time()})
441
+ )
442
+ tmp.replace(_UPDATE_CACHE_PATH)
443
+ except Exception:
444
+ pass
445
+
446
+
447
+ def _version_is_newer(candidate: str, reference: str) -> bool:
448
+ """Return True if candidate is strictly newer than reference (numeric tuple comparison)."""
449
+ def _parts(v: str) -> tuple[int, ...]:
450
+ return tuple(int(x) for x in v.split(".")[:4] if x.isdigit())
451
+ try:
452
+ return _parts(candidate) > _parts(reference)
453
+ except Exception:
454
+ return False
455
+
456
+
457
+ def _launch_update_check_thread() -> None:
458
+ """Start a fire-and-forget daemon thread that fetches the latest PyPI version.
459
+
460
+ The thread only runs if the cache is stale (> TTL). Because it is a daemon
461
+ thread the OS kills it automatically when the main process exits, so it never
462
+ delays shutdown.
463
+ """
464
+ _, checked_at = _read_update_cache()
465
+ if time.time() - checked_at <= _UPDATE_CHECK_TTL:
466
+ return # Cache is fresh — nothing to do
467
+
468
+ import threading
469
+
470
+ def _worker() -> None:
471
+ try:
472
+ import httpx
473
+ with httpx.Client(timeout=5) as client:
474
+ resp = client.get("https://pypi.org/pypi/forgexa-cli/json")
475
+ latest = str(resp.json()["info"]["version"])
476
+ _write_update_cache(latest)
477
+ except Exception:
478
+ pass # Network errors are silently ignored
479
+
480
+ t = threading.Thread(target=_worker, daemon=True, name="forgexa-update-check")
481
+ t.start()
482
+
483
+
484
+ def _print_update_notice_if_available() -> None:
485
+ """Print an update notice to stderr when a newer version is cached.
486
+
487
+ Silently skipped when:
488
+ - FORGEXA_NO_UPDATE_CHECK=1 is set
489
+ - stderr is not a TTY (pipes, CI, scripts)
490
+ - The CI env var is set
491
+ - Output format is json (machine-readable)
492
+ """
493
+ if os.environ.get("FORGEXA_NO_UPDATE_CHECK"):
494
+ return
495
+ if os.environ.get("CI"):
496
+ return
497
+ if not sys.stderr.isatty():
498
+ return
499
+ if _output_format == "json":
500
+ return
501
+
502
+ current = _installed_cli_version()
503
+ if not current:
504
+ return
505
+
506
+ latest, _ = _read_update_cache()
507
+ if not latest or not _version_is_newer(latest, current):
508
+ return
509
+
510
+ print(
511
+ f"\n Update available: forgexa {current} → {latest}\n"
512
+ f" Run: forgexa upgrade\n",
513
+ file=sys.stderr,
514
+ )
515
+
516
+
517
+ # ── Daemon PID-file helpers ───────────────────────────────────────────────────
518
+
415
519
  def _daemon_pid_files() -> list[Path]:
416
520
  return [
417
521
  Path.home() / ".forgexa-daemon.pid",
@@ -505,19 +609,16 @@ def _request_json(
505
609
  timeout: int = 15,
506
610
  allow_refresh: bool = True,
507
611
  ) -> dict | list | None:
508
- import urllib.request
509
- import urllib.error
612
+ import httpx
510
613
 
511
614
  url = f"{_api_url()}/api/v1{path}"
512
- body = json.dumps(data).encode() if data is not None else None
513
- req = urllib.request.Request(url, data=body, headers=_headers(), method=method)
514
615
  try:
515
- with urllib.request.urlopen(req, timeout=timeout) as resp:
516
- content = resp.read()
517
- return json.loads(content) if content else None
518
- except urllib.error.HTTPError as e:
519
- body_text = e.read().decode(errors="replace")
520
- if e.code == 401 and allow_refresh and _refresh_access_token():
616
+ with httpx.Client(headers=_headers(), timeout=timeout) as client:
617
+ resp = client.request(method, url, json=data)
618
+ resp.raise_for_status()
619
+ return resp.json() if resp.content else None
620
+ except httpx.HTTPStatusError as e:
621
+ if e.response.status_code == 401 and allow_refresh and _refresh_access_token():
521
622
  return _request_json(
522
623
  path,
523
624
  method=method,
@@ -525,10 +626,11 @@ def _request_json(
525
626
  timeout=timeout,
526
627
  allow_refresh=False,
527
628
  )
528
- print(f"Error {e.code}: {body_text}", file=sys.stderr)
629
+ body_text = e.response.text
630
+ print(f"Error {e.response.status_code}: {body_text}", file=sys.stderr)
529
631
  sys.exit(1)
530
- except urllib.error.URLError as e:
531
- print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
632
+ except httpx.RequestError as e:
633
+ print(f"Connection error: {e}\nServer: {_api_url()}", file=sys.stderr)
532
634
  sys.exit(1)
533
635
 
534
636
 
@@ -664,15 +766,14 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
664
766
 
665
767
  def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
666
768
  """Like _get_runtimes() but returns [] instead of sys.exit on any error."""
667
- import urllib.request
769
+ import httpx
668
770
 
669
771
  path = "/runtimes" if include_all else "/runtimes/me"
670
772
  url = f"{_api_url()}/api/v1{path}"
671
- req = urllib.request.Request(url, headers=_headers(), method="GET")
672
773
  try:
673
- with urllib.request.urlopen(req, timeout=10) as resp:
674
- content = resp.read()
675
- result = json.loads(content) if content else []
774
+ with httpx.Client(headers=_headers(), timeout=10) as client:
775
+ resp = client.get(url)
776
+ result = resp.json() if resp.content else []
676
777
  return result if isinstance(result, list) else []
677
778
  except Exception:
678
779
  return []
@@ -1535,6 +1636,16 @@ def main() -> None:
1535
1636
  parser.print_help()
1536
1637
  return
1537
1638
 
1639
+ # Start background update-check now (writes cache if stale; zero latency).
1640
+ # Skip for `upgrade` (user is already updating) and raw log tailing
1641
+ # (`daemon logs -f` / `logs -f`) which never returns.
1642
+ _skip_update_check = cmd == "upgrade" or (
1643
+ cmd in ("daemon", "logs")
1644
+ and getattr(args, "follow", False)
1645
+ )
1646
+ if not _skip_update_check:
1647
+ _launch_update_check_thread()
1648
+
1538
1649
  dispatch: dict = {
1539
1650
  "version": cmd_version,
1540
1651
  "upgrade": cmd_upgrade,
@@ -1599,6 +1710,10 @@ def main() -> None:
1599
1710
  else:
1600
1711
  parser.print_help()
1601
1712
 
1713
+ # Show update notice as a footer (reads local cache — no network latency).
1714
+ if not _skip_update_check:
1715
+ _print_update_notice_if_available()
1716
+
1602
1717
 
1603
1718
  if __name__ == "__main__":
1604
1719
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.6
3
+ Version: 1.14.8
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -26,8 +26,10 @@ Classifier: Topic :: Software Development :: Quality Assurance
26
26
  Requires-Python: >=3.9
27
27
  Description-Content-Type: text/markdown
28
28
  Requires-Dist: httpx>=0.24
29
+ Requires-Dist: socksio>=1.0.0
29
30
  Provides-Extra: daemon
30
31
  Requires-Dist: httpx>=0.24; extra == "daemon"
32
+ Requires-Dist: socksio>=1.0.0; extra == "daemon"
31
33
 
32
34
  # Forgexa CLI
33
35
 
@@ -1,4 +1,6 @@
1
1
  httpx>=0.24
2
+ socksio>=1.0.0
2
3
 
3
4
  [daemon]
4
5
  httpx>=0.24
6
+ socksio>=1.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.14.6"
3
+ version = "1.14.8"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
@@ -27,11 +27,13 @@ classifiers = [
27
27
  ]
28
28
  # httpx is required for the daemon (core feature). Declaring it here ensures pip/pipx
29
29
  # installs it automatically — no fragile runtime auto-install needed.
30
- dependencies = ["httpx>=0.24"]
30
+ # socksio is required for SOCKS proxy support (httpx picks up ALL_PROXY/SOCKS_PROXY env vars
31
+ # automatically, and raises ImportError if socksio is missing).
32
+ dependencies = ["httpx>=0.24", "socksio>=1.0.0"]
31
33
 
32
34
  [project.optional-dependencies]
33
35
  # Keep [daemon] extra for backward compatibility / explicit opt-in.
34
- daemon = ["httpx>=0.24"]
36
+ daemon = ["httpx>=0.24", "socksio>=1.0.0"]
35
37
 
36
38
  [project.urls]
37
39
  Homepage = "https://forgexa.net"
File without changes
File without changes