forgexa-cli 1.8.4__tar.gz → 1.8.5__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.8.4
3
+ Version: 1.8.5
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
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.10
20
20
  Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
23
24
  Classifier: Topic :: Software Development :: Build Tools
24
25
  Classifier: Topic :: Software Development :: Quality Assurance
25
26
  Requires-Python: >=3.9
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.8.4"
2
+ __version__ = "1.8.5"
@@ -15,11 +15,14 @@ import sys
15
15
  # ── Python version gate — must run before any other imports ──────────────────
16
16
  # Emit a machine-readable DAEMON_ERROR so the desktop app shows a clear
17
17
  # message instead of a cryptic traceback.
18
+ # Minimum: 3.9 (macOS ships 3.9; CLI/daemon run on end-user machines).
19
+ # from __future__ import annotations (line 11) makes all X|Y union hints
20
+ # lazy strings on 3.9/3.10, so no runtime SyntaxError on those versions.
18
21
  if sys.version_info < (3, 9):
19
22
  _ver = f"{sys.version_info.major}.{sys.version_info.minor}"
20
23
  print(
21
- f"DAEMON_ERROR: Python {_ver} is too old. Forgexa Daemon requires Python 3.9 or "
22
- f"newer. Please upgrade Python from https://www.python.org/downloads/",
24
+ f"DAEMON_ERROR: Python {_ver} is too old. Forgexa Daemon requires Python "
25
+ f"3.9 or newer. Please upgrade Python from https://www.python.org/downloads/",
23
26
  file=sys.stderr,
24
27
  )
25
28
  sys.exit(1)
@@ -82,24 +85,27 @@ def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
82
85
 
83
86
  # Try pip --target first (most universally compatible).
84
87
  # Falls back to --user, then --break-system-packages as last resort.
85
- # We explicitly list httpcore alongside httpx because pip --target may
86
- # skip transitive deps it finds in system site-packages, even though
87
- # they won't be importable from the isolated deps directory.
88
+ # We explicitly list httpcore and certifi alongside httpx because pip
89
+ # --target may skip transitive deps it finds in system site-packages,
90
+ # even though they won't be importable from the isolated deps directory.
91
+ # certifi must be included so that its cacert.pem bundle is copied into
92
+ # the deps dir; without it httpx raises FileNotFoundError when building
93
+ # the default SSL context (observed on Python 3.14 standalone / Windows).
88
94
  strategies: list[tuple[str, list[str]]] = [
89
95
  (
90
96
  "pip install --target (isolated deps)",
91
97
  [python, "-m", "pip", "install", "--target", deps_dir,
92
- "--quiet", "--upgrade", "httpx>=0.24", "httpcore"],
98
+ "--quiet", "--upgrade", "httpx>=0.24", "httpcore", "certifi"],
93
99
  ),
94
100
  (
95
101
  "pip install --user",
96
102
  [python, "-m", "pip", "install", "--user", "--quiet",
97
- "httpx>=0.24", "httpcore"],
103
+ "httpx>=0.24", "httpcore", "certifi"],
98
104
  ),
99
105
  (
100
106
  "pip install --break-system-packages",
101
107
  [python, "-m", "pip", "install", "--quiet",
102
- "--break-system-packages", "httpx>=0.24", "httpcore"],
108
+ "--break-system-packages", "httpx>=0.24", "httpcore", "certifi"],
103
109
  ),
104
110
  ]
105
111
 
@@ -190,19 +196,33 @@ def _die_missing_httpx(detail: str) -> None:
190
196
  def _validate_httpx_imports() -> tuple[bool, str]:
191
197
  """Validate that httpx and its critical transitive deps are importable.
192
198
 
193
- A bare ``import httpx`` can succeed even when httpcore is missing,
194
- because httpx lazily imports its transport layer. We eagerly check
195
- the full chain so the daemon fails fast with a clear message instead
196
- of crashing mid-operation when ``httpx.AsyncClient()`` tries to load
197
- the transport.
199
+ A bare ``import httpx`` can succeed even when httpcore or certifi is
200
+ missing, because httpx lazily imports its transport and SSL layers.
201
+ We eagerly check the full chain so the daemon fails fast with a clear
202
+ message instead of crashing mid-operation.
198
203
 
199
- Returns (ok, missing_module_name).
204
+ Also verifies that certifi's CA bundle file actually exists on disk.
205
+ A ``pip install --target`` run may install certifi's Python package but
206
+ omit the package-data file (cacert.pem) when pip detects certifi in
207
+ the system site-packages and skips re-copying it. Without the bundle,
208
+ httpx raises FileNotFoundError when building the default SSL context
209
+ (observed on Python 3.14 standalone builds on Windows).
210
+
211
+ Returns (ok, missing_module_name). 'certifi:bundle' means certifi is
212
+ importable but its CA bundle file is absent.
200
213
  """
201
- for mod_name in ("httpx", "httpcore"):
214
+ for mod_name in ("httpx", "httpcore", "certifi"):
202
215
  try:
203
216
  __import__(mod_name)
204
217
  except ImportError:
205
218
  return False, mod_name
219
+ # Verify the certifi CA bundle file actually exists on disk
220
+ try:
221
+ import certifi as _certifi_check
222
+ if not os.path.isfile(_certifi_check.where()):
223
+ return False, "certifi:bundle"
224
+ except Exception:
225
+ return False, "certifi"
206
226
  return True, ""
207
227
 
208
228
 
@@ -223,8 +243,8 @@ if not _httpx_ok:
223
243
  if _httpx_missing != "httpx":
224
244
  shutil.rmtree(_HTTPX_DEPS_DIR, ignore_errors=True)
225
245
  for _mod_key in list(sys.modules):
226
- if _mod_key in ("httpx", "httpcore") or \
227
- _mod_key.startswith(("httpx.", "httpcore.")):
246
+ if _mod_key in ("httpx", "httpcore", "certifi") or \
247
+ _mod_key.startswith(("httpx.", "httpcore.", "certifi.")):
228
248
  del sys.modules[_mod_key]
229
249
 
230
250
  # Attempt auto-install to user-writable deps directory
@@ -245,6 +265,46 @@ import httpx # noqa: E402 — guaranteed available after validation above
245
265
 
246
266
  del _httpx_ok, _httpx_missing
247
267
 
268
+
269
+ def _make_httpx_ssl_context():
270
+ """Build an SSL context resilient to missing or mislocated certifi bundles.
271
+
272
+ On Windows 10/11 with Python 3.13+ / 3.14+, ``ssl.create_default_context()``
273
+ can use the built-in Windows certificate store directly and does NOT need
274
+ an external CA bundle file. However, older httpx versions call
275
+ ``certifi.where()`` and pass the resulting path to
276
+ ``ssl.create_default_context(cafile=...)``. When running from an
277
+ isolated deps directory, the certifi package may be importable (found
278
+ in system site-packages via sys.path) but its ``cacert.pem`` bundle may
279
+ not exist at the expected path, causing a ``FileNotFoundError``.
280
+
281
+ Strategy (in order):
282
+ 1. Explicit certifi bundle — portable; pinned root CAs.
283
+ 2. System truststore — works on Windows 11 (cert store), macOS
284
+ (Keychain), and Linux (/etc/ssl). No external file needed.
285
+ 3. httpx default (verify=True) — last resort; httpx makes its own
286
+ SSL choice.
287
+
288
+ Returns a value suitable for ``httpx.AsyncClient(verify=...)``.
289
+ """
290
+ import ssl as _ssl
291
+ # Strategy 1: explicit certifi bundle (most portable)
292
+ try:
293
+ import certifi as _certifi
294
+ cafile = _certifi.where()
295
+ if os.path.isfile(cafile):
296
+ return _ssl.create_default_context(cafile=cafile)
297
+ except Exception:
298
+ pass
299
+ # Strategy 2: system truststore (no external file needed)
300
+ try:
301
+ return _ssl.create_default_context()
302
+ except Exception:
303
+ pass
304
+ # Strategy 3: let httpx use its own SSL handling
305
+ return True
306
+
307
+
248
308
  # ── Settings: graceful fallback when running standalone (outside backend package) ──
249
309
  try:
250
310
  from app.config import settings
@@ -332,7 +392,7 @@ except (ImportError, ModuleNotFoundError):
332
392
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
333
393
  # Kept in sync with pyproject.toml version via bump-version.sh.
334
394
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
335
- DAEMON_VERSION = "1.8.4"
395
+ DAEMON_VERSION = "1.8.5"
336
396
 
337
397
 
338
398
  def _detect_client_type() -> str:
@@ -651,7 +711,8 @@ class AgentDiscovery:
651
711
  },
652
712
  "copilot": {
653
713
  "commands": ["copilot"],
654
- "detect": "copilot --version",
714
+ "detect": "copilot version",
715
+ "detect_alt": "copilot --version",
655
716
  "invoke_modes": ["cli"],
656
717
  "env_path_override": "FACTORY_COPILOT_PATH",
657
718
  "compatibility_level": "L3",
@@ -679,12 +740,30 @@ class AgentDiscovery:
679
740
  localappdata = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
680
741
  extra_dirs += [
681
742
  appdata / "npm", # npm -g installs
682
- localappdata / "Programs" / "Python" / "Scripts",
683
743
  home / ".opencode" / "bin",
684
744
  home / ".cargo" / "bin",
685
745
  home / ".bun" / "bin",
686
746
  home / "scoop" / "shims", # scoop package manager
687
747
  ]
748
+ # Python 3.x on Windows installs Scripts to a versioned subdirectory:
749
+ # %LOCALAPPDATA%\Programs\Python\Python312\Scripts (etc.).
750
+ # Glob all Python3* dirs so we catch whichever version is installed.
751
+ py_base = localappdata / "Programs" / "Python"
752
+ if py_base.is_dir():
753
+ for py_dir in sorted(py_base.glob("Python3*/"), reverse=True):
754
+ scripts = py_dir / "Scripts"
755
+ if scripts.is_dir():
756
+ extra_dirs.append(scripts)
757
+ # Flat path kept for compatibility with non-standard Python installers.
758
+ extra_dirs.append(py_base / "Scripts")
759
+ # GitHub Copilot CLI — VS Code extension installs the binary into
760
+ # AppData\Roaming\Code\User\globalStorage\github.copilot-chat\copilotCli\
761
+ # This dir is NOT in system PATH so the daemon must add it explicitly.
762
+ for vs_variant in ("Code", "Code - Insiders", "VSCodium"):
763
+ extra_dirs.append(
764
+ appdata / vs_variant / "User" / "globalStorage"
765
+ / "github.copilot-chat" / "copilotCli"
766
+ )
688
767
  # nvm-windows stores versions differently
689
768
  nvm_home = os.environ.get("NVM_HOME", "")
690
769
  if nvm_home:
@@ -756,6 +835,11 @@ class AgentDiscovery:
756
835
  resolved = shutil.which(cmd)
757
836
  if resolved:
758
837
  version = await self._get_version(spec["detect"])
838
+ if version is None and "detect_alt" in spec:
839
+ # Primary detect command failed; try the alternative.
840
+ # Example: new GitHub Copilot CLI 1.0.x uses `copilot version`
841
+ # as a subcommand while older releases used `copilot --version`.
842
+ version = await self._get_version(spec["detect_alt"])
759
843
  if version is None:
760
844
  # Binary found but version check failed — it is a stub or
761
845
  # not properly installed (e.g. copilot prompts to install).
@@ -785,6 +869,21 @@ class AgentDiscovery:
785
869
  import re
786
870
  try:
787
871
  parts = detect_cmd.split()
872
+ # On Windows, agent CLIs installed via npm create .cmd wrapper scripts
873
+ # (e.g. %APPDATA%\npm\copilot.cmd). Python's asyncio.create_subprocess_exec
874
+ # calls CreateProcess which cannot execute .cmd/.bat files directly —
875
+ # they need cmd.exe as the interpreter. Detect and wrap automatically.
876
+ if sys.platform == "win32":
877
+ resolved = shutil.which(parts[0])
878
+ if resolved:
879
+ ext = Path(resolved).suffix.lower()
880
+ if ext in ('.cmd', '.bat'):
881
+ parts = ['cmd', '/c', resolved] + parts[1:]
882
+ elif ext == '.ps1':
883
+ parts = [
884
+ 'powershell', '-NoProfile', '-NonInteractive',
885
+ '-Command', resolved,
886
+ ] + parts[1:]
788
887
  proc = await asyncio.create_subprocess_exec(
789
888
  *parts,
790
889
  stdin=asyncio.subprocess.DEVNULL,
@@ -3448,6 +3547,7 @@ class ServerConnection:
3448
3547
  self.runtime_id: str | None = None
3449
3548
  self.client = httpx.AsyncClient(
3450
3549
  headers={"Authorization": f"Bearer {api_token}"} if api_token else {},
3550
+ verify=_make_httpx_ssl_context(),
3451
3551
  )
3452
3552
  self.heartbeat: HeartbeatService | None = None
3453
3553
  self.poller: TaskPoller | None = None
@@ -5824,17 +5924,57 @@ class RuntimeDaemon:
5824
5924
  )
5825
5925
  return f"Push failed: {exc}"
5826
5926
  else:
5827
- logger.error(
5828
- "SAFETY: Refusing to push %s remote has %d commit(s) "
5829
- "not in local branch. This would destroy prior work. "
5830
- "Remote-only commits:\n%s",
5831
- branch, remote_count, remote_ahead,
5832
- )
5833
- return (
5834
- f"Push refused: remote branch '{branch}' has {remote_count} "
5835
- f"commit(s) not in local history. Force-pushing would "
5836
- f"destroy prior implementation work."
5927
+ # Remote has genuinely new commits not in local history.
5928
+ # Before refusing, attempt fetch + rebase: if the local
5929
+ # commits are documentation-only (analysis) or purely
5930
+ # additive they will rebase cleanly onto the remote HEAD.
5931
+ logger.warning(
5932
+ "Branch %s: remote has %d commit(s) not in local history — "
5933
+ "attempting fetch+rebase recovery before refusing push",
5934
+ branch, remote_count,
5837
5935
  )
5936
+ try:
5937
+ # 1. Fetch the specific remote branch
5938
+ await git(
5939
+ "fetch", "origin", branch,
5940
+ cwd=workspace_path,
5941
+ )
5942
+ # 2. Rebase local commits onto the updated remote HEAD
5943
+ await git(
5944
+ "-c", "user.name=Forgexa Agent",
5945
+ "-c", "user.email=agent@forgexa.net",
5946
+ "rebase", f"origin/{branch}",
5947
+ cwd=workspace_path,
5948
+ )
5949
+ # 3. Push normally (no force needed — we're ahead of origin now)
5950
+ await git(
5951
+ "push", "-u", "origin", branch,
5952
+ cwd=workspace_path, project_key=project_key,
5953
+ )
5954
+ logger.info(
5955
+ "Fetch+rebase recovery succeeded for branch %s "
5956
+ "(%d remote commit(s) incorporated)",
5957
+ branch, remote_count,
5958
+ )
5959
+ return None
5960
+ except RuntimeError as rebase_exc:
5961
+ rebase_exc_str = str(rebase_exc)
5962
+ # Abort any in-progress rebase to leave workspace clean
5963
+ try:
5964
+ await git("rebase", "--abort", cwd=workspace_path)
5965
+ except RuntimeError:
5966
+ pass
5967
+ logger.error(
5968
+ "Fetch+rebase recovery failed for branch %s: %s — "
5969
+ "refusing push to protect remote work",
5970
+ branch, rebase_exc_str,
5971
+ )
5972
+ return (
5973
+ f"Push refused: remote branch '{branch}' has {remote_count} "
5974
+ f"commit(s) not in local history, and automatic rebase failed "
5975
+ f"(likely a merge conflict). Manual resolution required. "
5976
+ f"Details: {rebase_exc_str[:300]}"
5977
+ )
5838
5978
 
5839
5979
  logger.info("Found unpushed commits on %s, pushing...", branch)
5840
5980
  last_push_exc: Exception | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.8.4
3
+ Version: 1.8.5
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
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.10
20
20
  Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
23
24
  Classifier: Topic :: Software Development :: Build Tools
24
25
  Classifier: Topic :: Software Development :: Quality Assurance
25
26
  Requires-Python: >=3.9
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.8.4"
3
+ version = "1.8.5"
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" }
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.11",
22
22
  "Programming Language :: Python :: 3.12",
23
23
  "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
24
25
  "Topic :: Software Development :: Build Tools",
25
26
  "Topic :: Software Development :: Quality Assurance",
26
27
  ]
File without changes
File without changes