forgexa-cli 1.29.4__tar.gz → 1.30.0__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.
Files changed (27) hide show
  1. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/agent_core.py +6 -5
  4. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/daemon.py +43 -8
  5. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/main.py +247 -17
  6. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/pyproject.toml +1 -1
  8. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_auth_and_runtime_commands.py +178 -2
  9. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_expiry_warnings_and_revoke.py +13 -0
  10. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_runtime_credentials.py +14 -0
  11. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_upgrade_observability.py +17 -0
  12. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/README.md +0 -0
  13. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/_build_config.py +0 -0
  14. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/_local_bind.py +0 -0
  15. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/autoupgrade.py +0 -0
  16. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/py.typed +0 -0
  17. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  18. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  19. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  20. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/requires.txt +0 -0
  21. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  22. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/setup.cfg +0 -0
  23. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_autoupgrade.py +0 -0
  24. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_check_command.py +0 -0
  25. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_silent_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.29.4
3
+ Version: 1.30.0
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-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.29.4"
2
+ __version__ = "1.30.0"
@@ -213,11 +213,11 @@ def _redirect_prompt_through_task_file(
213
213
  return prompt, None
214
214
 
215
215
 
216
- def _agent_process_group_kwargs() -> dict[str, bool]:
217
- """Isolate agent process groups where the platform supports POSIX sessions."""
218
- # Windows cleanup uses taskkill /T, and starting cmd.exe with a new session
219
- # differs from the command path proven during agent discovery.
220
- return {"start_new_session": True} if sys.platform != "win32" else {}
216
+ def _agent_process_group_kwargs() -> dict[str, int | bool]:
217
+ """Return platform-specific subprocess kwargs for headless subprocesses."""
218
+ if sys.platform == "win32":
219
+ return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)}
220
+ return {"start_new_session": True}
221
221
 
222
222
 
223
223
  def _parse_semver(v: str) -> tuple[int, int, int] | None:
@@ -542,6 +542,7 @@ class AgentDiscovery:
542
542
  stdin=asyncio.subprocess.DEVNULL,
543
543
  stdout=asyncio.subprocess.PIPE,
544
544
  stderr=asyncio.subprocess.PIPE,
545
+ **_agent_process_group_kwargs(),
545
546
  )
546
547
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
547
548
  if proc.returncode != 0:
@@ -910,7 +910,7 @@ except (ImportError, ModuleNotFoundError):
910
910
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
911
911
  # Kept in sync with pyproject.toml version via bump-version.sh.
912
912
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
913
- DAEMON_VERSION = "1.29.4"
913
+ DAEMON_VERSION = "1.30.0"
914
914
 
915
915
 
916
916
  def _detect_client_type() -> str:
@@ -1955,6 +1955,7 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
1955
1955
  cwd=str(local_path),
1956
1956
  stdout=asyncio.subprocess.PIPE,
1957
1957
  stderr=asyncio.subprocess.DEVNULL,
1958
+ **_agent_process_group_kwargs(),
1958
1959
  )
1959
1960
  stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
1960
1961
  except asyncio.TimeoutError:
@@ -2911,6 +2912,7 @@ class WorkspaceManager:
2911
2912
  cwd=str(ws_path),
2912
2913
  stdout=asyncio.subprocess.PIPE,
2913
2914
  stderr=asyncio.subprocess.PIPE,
2915
+ **_agent_process_group_kwargs(),
2914
2916
  )
2915
2917
  st_out, _ = await asyncio.wait_for(st_proc.communicate(), timeout=10)
2916
2918
  if st_proc.returncode == 0 and st_out.decode().strip():
@@ -3083,6 +3085,7 @@ class WorkspaceManager:
3083
3085
  cwd=str(repo_path),
3084
3086
  stdout=asyncio.subprocess.PIPE,
3085
3087
  stderr=asyncio.subprocess.PIPE,
3088
+ **_agent_process_group_kwargs(),
3086
3089
  )
3087
3090
  local_out, _ = await asyncio.wait_for(local_proc.communicate(), timeout=10)
3088
3091
  if local_proc.returncode != 0:
@@ -3111,6 +3114,7 @@ class WorkspaceManager:
3111
3114
  cwd=str(repo_path),
3112
3115
  stdout=asyncio.subprocess.PIPE,
3113
3116
  stderr=asyncio.subprocess.PIPE,
3117
+ **_agent_process_group_kwargs(),
3114
3118
  )
3115
3119
  ahead_out, _ = await asyncio.wait_for(ahead_proc.communicate(), timeout=10)
3116
3120
  if ahead_proc.returncode == 0:
@@ -3132,6 +3136,7 @@ class WorkspaceManager:
3132
3136
  cwd=str(repo_path),
3133
3137
  stdout=asyncio.subprocess.PIPE,
3134
3138
  stderr=asyncio.subprocess.PIPE,
3139
+ **_agent_process_group_kwargs(),
3135
3140
  )
3136
3141
  out, err = await asyncio.wait_for(check_proc.communicate(), timeout=10)
3137
3142
  if check_proc.returncode != 0:
@@ -3984,6 +3989,7 @@ class WorkspaceManager:
3984
3989
  cwd=str(main_repo),
3985
3990
  stdout=asyncio.subprocess.PIPE,
3986
3991
  stderr=asyncio.subprocess.PIPE,
3992
+ **_agent_process_group_kwargs(),
3987
3993
  )
3988
3994
  await check_proc.communicate()
3989
3995
  branch_exists_remote = check_proc.returncode == 0
@@ -5863,10 +5869,6 @@ class ProcessManager:
5863
5869
  # stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
5864
5870
  # CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
5865
5871
  # headless environment and processes -p as the complete task specification.
5866
- _win_flags: dict = {}
5867
- if sys.platform == "win32":
5868
- _win_flags["creationflags"] = subprocess.CREATE_NO_WINDOW
5869
-
5870
5872
  try:
5871
5873
  proc = await asyncio.create_subprocess_exec(
5872
5874
  *cmd,
@@ -5888,7 +5890,6 @@ class ProcessManager:
5888
5890
  env=env,
5889
5891
  limit=100 * 1024 * 1024,
5890
5892
  **_agent_process_group_kwargs(),
5891
- **_win_flags,
5892
5893
  )
5893
5894
  self.active_processes[task_id] = proc
5894
5895
  stdout, stderr, returncode = await self._stream_process(
@@ -6187,6 +6188,7 @@ class ProcessManager:
6187
6188
  stdout=asyncio.subprocess.PIPE,
6188
6189
  stderr=asyncio.subprocess.PIPE,
6189
6190
  cwd=str(cwd),
6191
+ **_agent_process_group_kwargs(),
6190
6192
  )
6191
6193
  stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
6192
6194
  stat_output = stdout.decode()
@@ -6197,6 +6199,7 @@ class ProcessManager:
6197
6199
  stdout=asyncio.subprocess.PIPE,
6198
6200
  stderr=asyncio.subprocess.PIPE,
6199
6201
  cwd=str(cwd),
6202
+ **_agent_process_group_kwargs(),
6200
6203
  )
6201
6204
  stdout2, _ = await asyncio.wait_for(proc2.communicate(), timeout=10)
6202
6205
  stat_output += stdout2.decode()
@@ -6207,6 +6210,7 @@ class ProcessManager:
6207
6210
  stdout=asyncio.subprocess.PIPE,
6208
6211
  stderr=asyncio.subprocess.PIPE,
6209
6212
  cwd=str(cwd),
6213
+ **_agent_process_group_kwargs(),
6210
6214
  )
6211
6215
  stdout3, _ = await asyncio.wait_for(proc3.communicate(), timeout=10)
6212
6216
  files = [f for f in stdout3.decode().strip().split("\n") if f]
@@ -6218,6 +6222,7 @@ class ProcessManager:
6218
6222
  stdout=asyncio.subprocess.PIPE,
6219
6223
  stderr=asyncio.subprocess.PIPE,
6220
6224
  cwd=str(cwd),
6225
+ **_agent_process_group_kwargs(),
6221
6226
  )
6222
6227
  stdout4, _ = await asyncio.wait_for(proc4.communicate(), timeout=10)
6223
6228
  shortstat = stdout4.decode()
@@ -6232,6 +6237,7 @@ class ProcessManager:
6232
6237
  stdout=asyncio.subprocess.PIPE,
6233
6238
  stderr=asyncio.subprocess.PIPE,
6234
6239
  cwd=str(cwd),
6240
+ **_agent_process_group_kwargs(),
6235
6241
  )
6236
6242
  stdout5, _ = await asyncio.wait_for(proc5.communicate(), timeout=10)
6237
6243
  info["branch"] = stdout5.decode().strip()
@@ -6242,6 +6248,7 @@ class ProcessManager:
6242
6248
  stdout=asyncio.subprocess.PIPE,
6243
6249
  stderr=asyncio.subprocess.PIPE,
6244
6250
  cwd=str(cwd),
6251
+ **_agent_process_group_kwargs(),
6245
6252
  )
6246
6253
  stdout6, _ = await asyncio.wait_for(proc6.communicate(), timeout=10)
6247
6254
  info["commit_sha"] = stdout6.decode().strip()
@@ -6287,6 +6294,7 @@ class ProcessManager:
6287
6294
  stdout=asyncio.subprocess.PIPE,
6288
6295
  stderr=asyncio.subprocess.PIPE,
6289
6296
  cwd=str(cwd),
6297
+ **_agent_process_group_kwargs(),
6290
6298
  )
6291
6299
  mb_stdout, _ = await asyncio.wait_for(proc_mb.communicate(), timeout=10)
6292
6300
  if proc_mb.returncode == 0 and mb_stdout.strip():
@@ -6299,6 +6307,7 @@ class ProcessManager:
6299
6307
  stdout=asyncio.subprocess.PIPE,
6300
6308
  stderr=asyncio.subprocess.PIPE,
6301
6309
  cwd=str(cwd),
6310
+ **_agent_process_group_kwargs(),
6302
6311
  )
6303
6312
  stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
6304
6313
  files = [f for f in stdout.decode().strip().split("\n") if f]
@@ -6310,6 +6319,7 @@ class ProcessManager:
6310
6319
  stdout=asyncio.subprocess.PIPE,
6311
6320
  stderr=asyncio.subprocess.PIPE,
6312
6321
  cwd=str(cwd),
6322
+ **_agent_process_group_kwargs(),
6313
6323
  )
6314
6324
  stdout2, _ = await asyncio.wait_for(proc2.communicate(), timeout=10)
6315
6325
  shortstat = stdout2.decode()
@@ -6324,6 +6334,7 @@ class ProcessManager:
6324
6334
  stdout=asyncio.subprocess.PIPE,
6325
6335
  stderr=asyncio.subprocess.PIPE,
6326
6336
  cwd=str(cwd),
6337
+ **_agent_process_group_kwargs(),
6327
6338
  )
6328
6339
  stdout3, _ = await asyncio.wait_for(proc3.communicate(), timeout=10)
6329
6340
  info["branch"] = stdout3.decode().strip()
@@ -6334,6 +6345,7 @@ class ProcessManager:
6334
6345
  stdout=asyncio.subprocess.PIPE,
6335
6346
  stderr=asyncio.subprocess.PIPE,
6336
6347
  cwd=str(cwd),
6348
+ **_agent_process_group_kwargs(),
6337
6349
  )
6338
6350
  stdout4, _ = await asyncio.wait_for(proc4.communicate(), timeout=10)
6339
6351
  info["commit_sha"] = stdout4.decode().strip()
@@ -7759,6 +7771,18 @@ def _validate_test_evidence(
7759
7771
  return issues
7760
7772
 
7761
7773
 
7774
+ def _configured_daemon_api_token() -> str:
7775
+ token = str(settings.DAEMON_API_TOKEN or "").strip()
7776
+ if token.startswith("pat_"):
7777
+ logger.warning(
7778
+ "Ignoring personal access token for daemon authentication because "
7779
+ "PATs cannot register runtimes. Use an enrolled runtime credential "
7780
+ "or a saved login session instead."
7781
+ )
7782
+ return ""
7783
+ return token
7784
+
7785
+
7762
7786
  class RuntimeDaemon:
7763
7787
  """Main Daemon orchestrator — runs on the host machine.
7764
7788
 
@@ -7778,8 +7802,8 @@ class RuntimeDaemon:
7778
7802
  # prevents duplicate registrations when the hostname changes.
7779
7803
  self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
7780
7804
  self.server_urls = settings.get_daemon_server_urls()
7781
- self._has_explicit_api_token = bool(str(settings.DAEMON_API_TOKEN or "").strip())
7782
- self.api_token = settings.DAEMON_API_TOKEN
7805
+ self.api_token = _configured_daemon_api_token()
7806
+ self._has_explicit_api_token = bool(self.api_token)
7783
7807
  # Fallback token for connections whose issuer has no stored credentials
7784
7808
  # yet: the ~/.forgexa/token compat mirror written by `forgexa login`.
7785
7809
  # Issuer-scoped credentials take precedence per connection (see
@@ -11520,6 +11544,7 @@ class RuntimeDaemon:
11520
11544
  cwd=str(workspace_path),
11521
11545
  stdout=asyncio.subprocess.PIPE,
11522
11546
  stderr=asyncio.subprocess.PIPE,
11547
+ **_agent_process_group_kwargs(),
11523
11548
  )
11524
11549
  await proc.communicate()
11525
11550
  except Exception as exc:
@@ -11574,6 +11599,7 @@ class RuntimeDaemon:
11574
11599
  cwd=str(workspace_path),
11575
11600
  stdout=asyncio.subprocess.PIPE,
11576
11601
  stderr=asyncio.subprocess.PIPE,
11602
+ **_agent_process_group_kwargs(),
11577
11603
  )
11578
11604
  stdout, stderr = await proc.communicate()
11579
11605
  if proc.returncode != 0:
@@ -11690,6 +11716,7 @@ class RuntimeDaemon:
11690
11716
  env={**os.environ,
11691
11717
  "GIT_AUTHOR_NAME": _git_name, "GIT_AUTHOR_EMAIL": _git_email,
11692
11718
  "GIT_COMMITTER_NAME": _git_name, "GIT_COMMITTER_EMAIL": _git_email},
11719
+ **_agent_process_group_kwargs(),
11693
11720
  )
11694
11721
  commit_stdout, commit_stderr = await proc.communicate()
11695
11722
  if proc.returncode != 0:
@@ -11790,6 +11817,7 @@ class RuntimeDaemon:
11790
11817
  cwd=str(workspace_path),
11791
11818
  stdout=asyncio.subprocess.PIPE,
11792
11819
  stderr=asyncio.subprocess.PIPE,
11820
+ **_agent_process_group_kwargs(),
11793
11821
  )
11794
11822
  stdout, _ = await proc.communicate()
11795
11823
 
@@ -11808,6 +11836,7 @@ class RuntimeDaemon:
11808
11836
  cwd=str(workspace_path),
11809
11837
  stdout=asyncio.subprocess.PIPE,
11810
11838
  stderr=asyncio.subprocess.PIPE,
11839
+ **_agent_process_group_kwargs(),
11811
11840
  )
11812
11841
  add_stdout, add_stderr = await proc.communicate()
11813
11842
  if proc.returncode != 0:
@@ -11859,6 +11888,7 @@ class RuntimeDaemon:
11859
11888
  env={**os.environ,
11860
11889
  "GIT_AUTHOR_NAME": _git_name, "GIT_AUTHOR_EMAIL": _git_email,
11861
11890
  "GIT_COMMITTER_NAME": _git_name, "GIT_COMMITTER_EMAIL": _git_email},
11891
+ **_agent_process_group_kwargs(),
11862
11892
  )
11863
11893
  commit_stdout, commit_stderr = await proc.communicate()
11864
11894
  if proc.returncode != 0:
@@ -11948,6 +11978,7 @@ class RuntimeDaemon:
11948
11978
  cwd=str(cwd),
11949
11979
  stdout=asyncio.subprocess.PIPE,
11950
11980
  stderr=asyncio.subprocess.PIPE,
11981
+ **_agent_process_group_kwargs(),
11951
11982
  )
11952
11983
  out, _ = await proc.communicate()
11953
11984
  for line in out.decode(errors="replace").strip().splitlines():
@@ -11961,6 +11992,7 @@ class RuntimeDaemon:
11961
11992
  cwd=str(cwd),
11962
11993
  stdout=asyncio.subprocess.PIPE,
11963
11994
  stderr=asyncio.subprocess.PIPE,
11995
+ **_agent_process_group_kwargs(),
11964
11996
  )
11965
11997
  out, _ = await proc.communicate()
11966
11998
  for line in out.decode(errors="replace").strip().splitlines():
@@ -11989,6 +12021,7 @@ class RuntimeDaemon:
11989
12021
  cwd=str(cwd),
11990
12022
  stdout=asyncio.subprocess.PIPE,
11991
12023
  stderr=asyncio.subprocess.PIPE,
12024
+ **_agent_process_group_kwargs(),
11992
12025
  )
11993
12026
  out, _ = await proc.communicate()
11994
12027
  stat_lines = out.decode(errors="replace").strip().splitlines()
@@ -12017,6 +12050,7 @@ class RuntimeDaemon:
12017
12050
  cwd=str(cwd),
12018
12051
  stdout=asyncio.subprocess.PIPE,
12019
12052
  stderr=asyncio.subprocess.PIPE,
12053
+ **_agent_process_group_kwargs(),
12020
12054
  )
12021
12055
  out, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
12022
12056
  except Exception:
@@ -13061,6 +13095,7 @@ class RuntimeDaemon:
13061
13095
  cwd=str(workspace_path),
13062
13096
  stdout=asyncio.subprocess.PIPE,
13063
13097
  stderr=asyncio.subprocess.PIPE,
13098
+ **_agent_process_group_kwargs(),
13064
13099
  )
13065
13100
  stdout, _ = await proc.communicate()
13066
13101
  remaining = [f.strip() for f in stdout.decode().strip().splitlines() if f.strip()]
@@ -415,6 +415,14 @@ def _token() -> str | None:
415
415
  return cfg.get("token") or None
416
416
 
417
417
 
418
+ def _daemon_token_environment() -> tuple[str | None, bool]:
419
+ for name in ("DAEMON_API_TOKEN", "FORGEXA_TOKEN"):
420
+ token = str(os.environ.get(name) or "").strip()
421
+ if token:
422
+ return name, token.startswith("pat_")
423
+ return None, False
424
+
425
+
418
426
  # ── Cross-process refresh coordination (~/.forgexa/auth-refresh.<hash>.lock) ──
419
427
  #
420
428
  # CLI and daemon processes share the same on-disk session credentials, so a
@@ -680,7 +688,19 @@ def _cli_distribution() -> importlib_metadata.Distribution | None:
680
688
 
681
689
  def _installed_cli_version() -> str | None:
682
690
  dist = _cli_distribution()
683
- return dist.version if dist is not None else None
691
+ if dist is None:
692
+ return None
693
+
694
+ direct_url = _direct_url_metadata(dist)
695
+ dir_info = direct_url.get("dir_info") if isinstance(direct_url, dict) else None
696
+ if isinstance(dir_info, dict) and dir_info.get("editable"):
697
+ from forgexa_cli import __version__
698
+
699
+ runtime_version = str(__version__).strip()
700
+ if runtime_version:
701
+ return runtime_version
702
+
703
+ return dist.version
684
704
 
685
705
 
686
706
  def _distribution_root(dist: importlib_metadata.Distribution | None) -> Path | None:
@@ -820,13 +840,6 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
820
840
  "Use your original install command to update it manually."
821
841
  )
822
842
 
823
- dist_root = _distribution_root(dist)
824
- if not _running_from_distribution_root(dist_root):
825
- raise RuntimeError(
826
- "The current process is not running from the installed forgexa-cli package. "
827
- "Run the installed `forgexa` command instead of a source checkout when using `forgexa upgrade`."
828
- )
829
-
830
843
  unsupported_source = _unsupported_upgrade_source(_direct_url_metadata(dist))
831
844
  if unsupported_source:
832
845
  raise RuntimeError(
@@ -834,6 +847,13 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
834
847
  f"({unsupported_source}). Reinstall it manually using the same source instead of `forgexa upgrade`."
835
848
  )
836
849
 
850
+ dist_root = _distribution_root(dist)
851
+ if not _running_from_distribution_root(dist_root):
852
+ raise RuntimeError(
853
+ "The current process is not running from the installed forgexa-cli package. "
854
+ "Run the installed `forgexa` command instead of a source checkout when using `forgexa upgrade`."
855
+ )
856
+
837
857
  current_version = dist.version
838
858
  python = sys.executable or "python3"
839
859
 
@@ -1006,6 +1026,7 @@ def _maybe_install_update() -> None:
1006
1026
  return
1007
1027
  if autoupgrade.has_fresh_active_install(state, target_version):
1008
1028
  return
1029
+
1009
1030
  if not autoupgrade.try_acquire_lock(target_version):
1010
1031
  return
1011
1032
 
@@ -1166,7 +1187,6 @@ def _launch_update_check_process(allow_install: bool) -> None:
1166
1187
  except Exception:
1167
1188
  pass
1168
1189
 
1169
-
1170
1190
  def _format_epoch(value: object) -> str:
1171
1191
  """Format an epoch-seconds timestamp for human display. Never raises."""
1172
1192
  if not isinstance(value, (int, float)):
@@ -1199,7 +1219,6 @@ def _print_background_install_success_notice_once(current_version: str) -> bool:
1199
1219
  autoupgrade.write_state(state)
1200
1220
  return True
1201
1221
 
1202
-
1203
1222
  def _print_update_notice_if_available() -> None:
1204
1223
  """Print an update notice to stderr when a newer version is cached, or a
1205
1224
  one-time notice when a silent background install just completed.
@@ -1426,6 +1445,154 @@ def _start_daemon_after_upgrade() -> tuple[bool, str | None]:
1426
1445
  return True, server_url
1427
1446
 
1428
1447
 
1448
+ def _windows_upgrade_launcher_paths() -> list[str]:
1449
+ python_path = Path(sys.executable)
1450
+ invocation = Path(sys.argv[0])
1451
+ candidates = [
1452
+ invocation,
1453
+ invocation.with_suffix(".exe"),
1454
+ python_path.parent / "forgexa.exe",
1455
+ python_path.parent / "Scripts" / "forgexa.exe",
1456
+ ]
1457
+ if invocation.stem.lower().endswith("-script"):
1458
+ candidates.append(invocation.with_name(f"{invocation.stem[:-7]}.exe"))
1459
+ paths: list[str] = []
1460
+ for candidate in candidates:
1461
+ try:
1462
+ resolved = candidate.resolve()
1463
+ except OSError:
1464
+ continue
1465
+ if (
1466
+ resolved.name.lower() == "forgexa.exe"
1467
+ and resolved.exists()
1468
+ and str(resolved) not in paths
1469
+ ):
1470
+ paths.append(str(resolved))
1471
+ return paths
1472
+
1473
+
1474
+ def _windows_path_is_unlocked(path: str) -> bool:
1475
+ """Return whether a launcher can be replaced without a sharing violation."""
1476
+ try:
1477
+ import ctypes
1478
+ from ctypes import wintypes
1479
+
1480
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
1481
+ create_file = kernel32.CreateFileW
1482
+ create_file.argtypes = [
1483
+ wintypes.LPCWSTR,
1484
+ wintypes.DWORD,
1485
+ wintypes.DWORD,
1486
+ ctypes.c_void_p,
1487
+ wintypes.DWORD,
1488
+ wintypes.DWORD,
1489
+ wintypes.HANDLE,
1490
+ ]
1491
+ create_file.restype = wintypes.HANDLE
1492
+ close_handle = kernel32.CloseHandle
1493
+ close_handle.argtypes = [wintypes.HANDLE]
1494
+ close_handle.restype = wintypes.BOOL
1495
+ delete_access = 0x00010000
1496
+ share_all = 0x00000007
1497
+ handle = create_file(path, delete_access, share_all, None, 3, 0, None)
1498
+ if handle == ctypes.c_void_p(-1).value:
1499
+ return False
1500
+ close_handle(handle)
1501
+ return True
1502
+ except Exception:
1503
+ return False
1504
+
1505
+
1506
+ def _wait_for_windows_upgrade_release(parent_pid: int, launcher_paths: list[str]) -> bool:
1507
+ deadline = time.monotonic() + 30.0
1508
+ while time.monotonic() < deadline:
1509
+ parent_exited = not _windows_process_is_running(parent_pid)
1510
+ launchers_released = all(_windows_path_is_unlocked(path) for path in launcher_paths)
1511
+ if parent_exited and launchers_released:
1512
+ return True
1513
+ time.sleep(0.1)
1514
+ return False
1515
+
1516
+
1517
+ def _run_windows_upgrade_worker(payload: str) -> None:
1518
+ """Run a deferred Windows upgrade after the invoking launcher exits."""
1519
+ try:
1520
+ data = json.loads(payload)
1521
+ command = data["command"]
1522
+ parent_pid = int(data["parent_pid"])
1523
+ launcher_paths = data.get("launcher_paths", [])
1524
+ daemon_stopped = bool(data.get("daemon_stopped"))
1525
+ target_version = str(data.get("target_version") or "latest")
1526
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
1527
+ return
1528
+ if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
1529
+ return
1530
+ if not isinstance(launcher_paths, list) or not all(isinstance(path, str) for path in launcher_paths):
1531
+ return
1532
+
1533
+ try:
1534
+ log_path = _upgrade_log_path()
1535
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1536
+ with log_path.open("a", encoding="utf-8") as log_file:
1537
+ log_file.write(
1538
+ f"\n[{time.strftime('%Y-%m-%dT%H:%M:%S')}] "
1539
+ f"Deferred Windows upgrade to {target_version}: {shlex.join(command)}\n"
1540
+ )
1541
+ if not _wait_for_windows_upgrade_release(parent_pid, launcher_paths):
1542
+ log_file.write("Upgrade aborted: timed out waiting for the CLI launcher to exit.\n")
1543
+ return
1544
+ try:
1545
+ result = subprocess.run(command, stdout=log_file, stderr=subprocess.STDOUT)
1546
+ except OSError as exc:
1547
+ log_file.write(f"Upgrade failed to start: {exc}\n")
1548
+ return
1549
+ if result.returncode != 0:
1550
+ log_file.write(f"Upgrade failed (returncode={result.returncode}).\n")
1551
+ return
1552
+ log_file.write("Upgrade complete.\n")
1553
+ if daemon_stopped:
1554
+ restarted, server_url = _start_daemon_after_upgrade()
1555
+ if restarted:
1556
+ detail = f" (server={server_url})" if server_url else ""
1557
+ log_file.write(f"Daemon restarted in background{detail}.\n")
1558
+ else:
1559
+ log_file.write("Restart the daemon with: forgexa daemon start\n")
1560
+ except OSError:
1561
+ return
1562
+
1563
+
1564
+ def _launch_windows_upgrade_worker(plan: UpgradePlan, daemon_stopped: bool) -> bool:
1565
+ payload = json.dumps(
1566
+ {
1567
+ "command": plan.command,
1568
+ "parent_pid": os.getpid(),
1569
+ "launcher_paths": _windows_upgrade_launcher_paths(),
1570
+ "daemon_stopped": daemon_stopped,
1571
+ "target_version": plan.target_version,
1572
+ }
1573
+ )
1574
+ try:
1575
+ subprocess.Popen(
1576
+ [
1577
+ sys.executable,
1578
+ "-c",
1579
+ "import sys; from forgexa_cli.main import _run_windows_upgrade_worker; "
1580
+ "_run_windows_upgrade_worker(sys.argv[1])",
1581
+ payload,
1582
+ ],
1583
+ stdin=subprocess.DEVNULL,
1584
+ stdout=subprocess.DEVNULL,
1585
+ stderr=subprocess.DEVNULL,
1586
+ creationflags=(
1587
+ getattr(subprocess, "CREATE_NO_WINDOW", 0)
1588
+ | getattr(subprocess, "DETACHED_PROCESS", 0)
1589
+ ),
1590
+ )
1591
+ except OSError:
1592
+ return False
1593
+ return True
1594
+
1595
+
1429
1596
  def _request_json(
1430
1597
  path: str,
1431
1598
  *,
@@ -1561,10 +1728,28 @@ def cmd_login(args: argparse.Namespace) -> None:
1561
1728
  ).strip()
1562
1729
  _save_tokens(token, refresh_token or None, server_url=_SERVER_URL_OVERRIDE if server else None)
1563
1730
 
1564
- active_server = _api_url()
1565
- print(f"Login successful.")
1566
- print(f" Server : {active_server}")
1567
- print(f" Session: saved (~/.forgexa/credentials.json)")
1731
+ print("Login successful.")
1732
+ print(f" Server : {_api_url()}")
1733
+ print(" Session: saved (~/.forgexa/credentials.json)")
1734
+ if not refresh_token:
1735
+ print(
1736
+ "Warning: no refresh token was saved; run 'forgexa daemon enroll' "
1737
+ "before starting a long-running daemon.",
1738
+ file=sys.stderr,
1739
+ )
1740
+ daemon_token_env, daemon_token_is_pat = _daemon_token_environment()
1741
+ if daemon_token_env and daemon_token_is_pat:
1742
+ print(
1743
+ f"Note: {daemon_token_env} is a personal access token and is "
1744
+ "ignored for daemon registration.",
1745
+ file=sys.stderr,
1746
+ )
1747
+ elif daemon_token_env:
1748
+ print(
1749
+ f"Warning: {daemon_token_env} overrides the saved login credentials "
1750
+ "for the daemon. Clear it unless it is a valid daemon credential.",
1751
+ file=sys.stderr,
1752
+ )
1568
1753
 
1569
1754
 
1570
1755
  def cmd_logout(_args: argparse.Namespace) -> None:
@@ -1623,6 +1808,14 @@ def cmd_config_show(_args: argparse.Namespace) -> None:
1623
1808
  print(f"Auth token : {'set' if token else 'not set'}")
1624
1809
  record = _credential_record(active_url)
1625
1810
  print(f"Refresh tok: {'set' if record.get('refresh_token') else 'not set'}")
1811
+ daemon_token_env, daemon_token_is_pat = _daemon_token_environment()
1812
+ if daemon_token_env:
1813
+ detail = (
1814
+ "ignored for daemon registration (personal access token)"
1815
+ if daemon_token_is_pat
1816
+ else "overrides saved daemon credentials"
1817
+ )
1818
+ print(f"Daemon env : {daemon_token_env} ({detail})")
1626
1819
  if _legacy_credentials_unattributed():
1627
1820
  print(
1628
1821
  "Legacy creds: present in ~/.forgexa/config but not attributable "
@@ -1792,8 +1985,12 @@ def cmd_daemon_status(args: argparse.Namespace) -> None:
1792
1985
  # ── Daemon credential source (raw values are never shown) ────────────────
1793
1986
  record = _credential_record()
1794
1987
  runtime_credential = str(record.get("runtime_credential") or "").strip()
1795
- if os.environ.get("DAEMON_API_TOKEN") or os.environ.get("FORGEXA_TOKEN"):
1796
- print("Credential : explicit env override (DAEMON_API_TOKEN/FORGEXA_TOKEN,")
1988
+ daemon_token_env, daemon_token_is_pat = _daemon_token_environment()
1989
+ if daemon_token_env and daemon_token_is_pat:
1990
+ print(f"Credential : {daemon_token_env} personal access token ignored")
1991
+ print(" (PATs cannot register runtimes)")
1992
+ if daemon_token_env and not daemon_token_is_pat:
1993
+ print(f"Credential : explicit env override ({daemon_token_env},")
1797
1994
  print(" not persisted, not automatically renewed)")
1798
1995
  elif runtime_credential:
1799
1996
  prefix = str(record.get("token_prefix") or runtime_credential[:12])
@@ -1957,6 +2154,19 @@ def cmd_daemon_start(args: argparse.Namespace) -> None:
1957
2154
 
1958
2155
  server_url = getattr(args, "server_url", None) or _api_url()
1959
2156
  os.environ["DAEMON_SERVER_URL"] = server_url
2157
+ daemon_token_env, daemon_token_is_pat = _daemon_token_environment()
2158
+ if daemon_token_env and daemon_token_is_pat:
2159
+ print(
2160
+ f"Note: daemon ignores {daemon_token_env} because personal access "
2161
+ "tokens cannot register runtimes."
2162
+ )
2163
+ print()
2164
+ elif daemon_token_env:
2165
+ print(
2166
+ f"Warning: daemon will use {daemon_token_env} instead of "
2167
+ "the saved login credentials. Clear it if the token is stale."
2168
+ )
2169
+ print()
1960
2170
 
1961
2171
  # Do NOT inject the interactive session token into the daemon via
1962
2172
  # DAEMON_API_TOKEN: the daemon reads and refreshes issuer-scoped
@@ -1999,7 +2209,17 @@ def cmd_daemon_start(args: argparse.Namespace) -> None:
1999
2209
 
2000
2210
  detected = _wait_and_show_daemon_agents(start_ts)
2001
2211
  if not detected:
2002
- if _token():
2212
+ if daemon_token_env and not daemon_token_is_pat:
2213
+ print(
2214
+ " (The explicit daemon token may be invalid. Clear the environment "
2215
+ "override and run 'forgexa login' again.)"
2216
+ )
2217
+ elif daemon_token_env:
2218
+ print(
2219
+ " (The personal access token was ignored. Run 'forgexa daemon enroll' "
2220
+ "or 'forgexa login' to provide daemon credentials.)"
2221
+ )
2222
+ elif _token():
2003
2223
  print(" (Agent registration not confirmed yet — check with: forgexa daemon status)")
2004
2224
  else:
2005
2225
  print(" (Not logged in — run 'forgexa login' to verify agent registration)")
@@ -3170,6 +3390,16 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
3170
3390
  print(f"Upgrade mode : {plan.installer}")
3171
3391
  print(f"Running : {shlex.join(plan.command)}")
3172
3392
 
3393
+ if sys.platform == "win32":
3394
+ if not _launch_windows_upgrade_worker(plan, daemon_stopped):
3395
+ print("Could not start the Windows upgrade worker.", file=sys.stderr)
3396
+ if daemon_stopped:
3397
+ print("Restart the daemon with: forgexa daemon start", file=sys.stderr)
3398
+ sys.exit(1)
3399
+ print("Upgrade continues in the background after this CLI process exits.")
3400
+ print(f"Upgrade log : {_upgrade_log_path()}")
3401
+ return
3402
+
3173
3403
  result = subprocess.run(plan.command)
3174
3404
  if result.returncode != 0:
3175
3405
  if daemon_stopped:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.29.4
3
+ Version: 1.30.0
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-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.29.4"
3
+ version = "1.30.0"
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 = "MIT"
@@ -65,6 +65,31 @@ def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.Monkey
65
65
  assert (tmp_path / ".forgexa" / "token").read_text() == "access-1"
66
66
 
67
67
 
68
+ def test_login_warns_when_refresh_token_is_missing_and_daemon_token_overrides(
69
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
70
+ ) -> None:
71
+ monkeypatch.setenv("HOME", str(tmp_path))
72
+ monkeypatch.setenv("FORGEXA_TOKEN", "explicit-token")
73
+ monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
74
+ monkeypatch.setattr(
75
+ main,
76
+ "_request_json",
77
+ lambda path, **kw: {"access_token": "access-1", "refresh_token": None},
78
+ )
79
+
80
+ main.cmd_login(
81
+ SimpleNamespace(
82
+ server="https://api.example.com",
83
+ email="user@example.com",
84
+ password="secret",
85
+ )
86
+ )
87
+
88
+ err = capsys.readouterr().err
89
+ assert "no refresh token was saved" in err
90
+ assert "FORGEXA_TOKEN overrides the saved login credentials" in err
91
+
92
+
68
93
  @pytest.mark.parametrize("suffix", [".cmd", ".bat"])
69
94
  def test_windows_batch_agent_commands_use_cmd_exe(
70
95
  suffix: str, monkeypatch: pytest.MonkeyPatch,
@@ -229,6 +254,25 @@ def test_cmd_daemon_start_no_stale_pid_message_on_clean_start(
229
254
  assert "stale daemon record" not in buffer.getvalue()
230
255
 
231
256
 
257
+ def test_cmd_daemon_start_explains_explicit_token_override(
258
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
259
+ ) -> None:
260
+ monkeypatch.setenv("HOME", str(tmp_path))
261
+ monkeypatch.setenv("FORGEXA_TOKEN", "stale-token")
262
+ monkeypatch.delenv("DAEMON_SERVER_URL", raising=False)
263
+ monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
264
+ monkeypatch.setattr(main, "_wait_and_show_daemon_agents", lambda start_ts: False)
265
+ monkeypatch.setattr(main.subprocess, "Popen", lambda *args, **kwargs: SimpleNamespace(pid=12345))
266
+
267
+ buffer = io.StringIO()
268
+ with contextlib.redirect_stdout(buffer):
269
+ main.cmd_daemon_start(SimpleNamespace(detach=True, server_url=None))
270
+
271
+ output = buffer.getvalue()
272
+ assert "daemon will use FORGEXA_TOKEN instead of the saved login credentials" in output
273
+ assert "The explicit daemon token may be invalid" in output
274
+
275
+
232
276
  def test_get_retries_once_after_refresh_on_401(
233
277
  tmp_path: Path,
234
278
  monkeypatch: pytest.MonkeyPatch,
@@ -643,10 +687,12 @@ def test_pep668_detection_returns_true_when_marker_present(tmp_path: Path, monke
643
687
  assert main._is_pep668_environment() is True
644
688
 
645
689
 
646
- def test_build_upgrade_plan_rejects_editable_install(monkeypatch: pytest.MonkeyPatch) -> None:
690
+ def test_build_upgrade_plan_rejects_editable_install_before_package_root_validation(
691
+ monkeypatch: pytest.MonkeyPatch,
692
+ ) -> None:
647
693
  monkeypatch.setattr(main, "_cli_distribution", lambda: SimpleNamespace(version="1.12.2"))
648
694
  monkeypatch.setattr(main, "_distribution_root", lambda dist: Path("/installed/site-packages"))
649
- monkeypatch.setattr(main, "_running_from_distribution_root", lambda root: True)
695
+ monkeypatch.setattr(main, "_running_from_distribution_root", lambda root: False)
650
696
  monkeypatch.setattr(main, "_direct_url_metadata", lambda dist: {"dir_info": {"editable": True}})
651
697
 
652
698
  with pytest.raises(RuntimeError, match="editable source install"):
@@ -683,6 +729,136 @@ def test_cmd_upgrade_runs_planned_command(monkeypatch: pytest.MonkeyPatch) -> No
683
729
  assert "Upgrade complete. Installed version: 1.12.3" in output
684
730
 
685
731
 
732
+ def test_cmd_upgrade_defers_windows_install_until_cli_exits(
733
+ monkeypatch: pytest.MonkeyPatch,
734
+ ) -> None:
735
+ plan = main.UpgradePlan(
736
+ installer="pip",
737
+ command=["python", "-m", "pip", "install", "--upgrade", "forgexa-cli==1.12.3"],
738
+ current_version="1.12.2",
739
+ target_version="1.12.3",
740
+ )
741
+ popen_calls: list[tuple[list[str], dict]] = []
742
+
743
+ monkeypatch.setattr(main.sys, "platform", "win32")
744
+ monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: plan)
745
+ monkeypatch.setattr(main, "_normalize_target_version", lambda value: value)
746
+ monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (False, None))
747
+ monkeypatch.setattr(
748
+ main,
749
+ "_windows_upgrade_launcher_paths",
750
+ lambda: [r"C:\Python312\Scripts\forgexa.exe"],
751
+ )
752
+ monkeypatch.setattr(
753
+ main.subprocess,
754
+ "Popen",
755
+ lambda command, **kwargs: popen_calls.append((command, kwargs)),
756
+ )
757
+ monkeypatch.setattr(
758
+ main.subprocess,
759
+ "run",
760
+ lambda *args, **kwargs: pytest.fail("Windows upgrade must not synchronously run pip"),
761
+ )
762
+
763
+ buffer = io.StringIO()
764
+ with contextlib.redirect_stdout(buffer):
765
+ main.cmd_upgrade(SimpleNamespace(target_version="1.12.3"))
766
+
767
+ assert len(popen_calls) == 1
768
+ command, kwargs = popen_calls[0]
769
+ assert command[:2] == [main.sys.executable, "-c"]
770
+ assert "_run_windows_upgrade_worker" in command[2]
771
+ assert json.loads(command[3]) == {
772
+ "command": plan.command,
773
+ "parent_pid": os.getpid(),
774
+ "launcher_paths": [r"C:\Python312\Scripts\forgexa.exe"],
775
+ "daemon_stopped": False,
776
+ "target_version": "1.12.3",
777
+ }
778
+ assert kwargs["stdin"] is main.subprocess.DEVNULL
779
+ assert kwargs["stdout"] is main.subprocess.DEVNULL
780
+ assert kwargs["stderr"] is main.subprocess.DEVNULL
781
+ assert "Upgrade continues in the background" in buffer.getvalue()
782
+
783
+
784
+ def test_windows_upgrade_launcher_paths_includes_user_console_launcher(
785
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
786
+ ) -> None:
787
+ scripts_dir = tmp_path / "AppData" / "Roaming" / "Python" / "Python312" / "Scripts"
788
+ scripts_dir.mkdir(parents=True)
789
+ launcher = scripts_dir / "forgexa.exe"
790
+ launcher.write_text("")
791
+
792
+ monkeypatch.setattr(main.sys, "argv", [str(scripts_dir / "forgexa-script.py")])
793
+ monkeypatch.setattr(main.sys, "executable", str(tmp_path / "Python312" / "python.exe"))
794
+
795
+ assert main._windows_upgrade_launcher_paths() == [str(launcher.resolve())]
796
+
797
+
798
+ def test_windows_upgrade_worker_waits_before_running_pip(
799
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
800
+ ) -> None:
801
+ events: list[str] = []
802
+ log_path = tmp_path / "upgrade.log"
803
+ monkeypatch.setattr(main, "_upgrade_log_path", lambda: log_path)
804
+ monkeypatch.setattr(
805
+ main,
806
+ "_wait_for_windows_upgrade_release",
807
+ lambda parent_pid, launcher_paths: events.append("wait") or True,
808
+ )
809
+ monkeypatch.setattr(
810
+ main.subprocess,
811
+ "run",
812
+ lambda command, **kwargs: events.append("run") or SimpleNamespace(returncode=0),
813
+ )
814
+
815
+ main._run_windows_upgrade_worker(
816
+ json.dumps(
817
+ {
818
+ "command": ["python", "-m", "pip", "install", "--upgrade", "forgexa-cli"],
819
+ "parent_pid": 4242,
820
+ "launcher_paths": [r"C:\Python312\Scripts\forgexa.exe"],
821
+ "daemon_stopped": False,
822
+ "target_version": "1.12.3",
823
+ }
824
+ )
825
+ )
826
+
827
+ assert events == ["wait", "run"]
828
+ assert "Deferred Windows upgrade to 1.12.3" in log_path.read_text()
829
+ assert "Upgrade complete." in log_path.read_text()
830
+
831
+
832
+ def test_windows_upgrade_worker_restarts_stopped_daemon_after_success(
833
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
834
+ ) -> None:
835
+ log_path = tmp_path / "upgrade.log"
836
+ restart_calls: list[bool] = []
837
+ monkeypatch.setattr(main, "_upgrade_log_path", lambda: log_path)
838
+ monkeypatch.setattr(main, "_wait_for_windows_upgrade_release", lambda *args: True)
839
+ monkeypatch.setattr(main.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
840
+ monkeypatch.setattr(
841
+ main,
842
+ "_start_daemon_after_upgrade",
843
+ lambda: restart_calls.append(True) or (True, "https://api.example.com"),
844
+ )
845
+
846
+ main._run_windows_upgrade_worker(
847
+ json.dumps(
848
+ {
849
+ "command": ["python", "-m", "pip", "install", "--upgrade", "forgexa-cli"],
850
+ "parent_pid": 4242,
851
+ "launcher_paths": [],
852
+ "daemon_stopped": True,
853
+ "target_version": "1.12.3",
854
+ }
855
+ )
856
+ )
857
+
858
+ assert restart_calls == [True]
859
+ assert "Daemon restarted in background (server=https://api.example.com)." in log_path.read_text()
860
+
861
+
686
862
  def test_cmd_upgrade_restarts_background_daemon_after_success(
687
863
  monkeypatch: pytest.MonkeyPatch,
688
864
  ) -> None:
@@ -236,6 +236,19 @@ def test_status_env_override_notes_no_auto_renewal(
236
236
  assert "not automatically renewed" in out
237
237
 
238
238
 
239
+ def test_status_personal_access_token_is_ignored_for_daemon(
240
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
241
+ ) -> None:
242
+ _write_credentials(tmp_path, {"runtime_credential": "frt_secret_value"})
243
+ monkeypatch.setenv("FORGEXA_TOKEN", "pat_personal_access_token")
244
+
245
+ out = _status_output(monkeypatch)
246
+
247
+ assert "FORGEXA_TOKEN personal access token ignored" in out
248
+ assert "PATs cannot register runtimes" in out
249
+ assert "runtime credential" in out
250
+
251
+
239
252
  # ── (c) forgexa daemon revoke-credential ─────────────────────────────────────
240
253
 
241
254
 
@@ -325,6 +325,20 @@ def test_token_for_server_priority(tmp_path: Path) -> None:
325
325
  assert runtime._token_for_server(_ISSUER) == "env-token"
326
326
 
327
327
 
328
+ def test_personal_access_token_does_not_override_daemon_credentials(
329
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture,
330
+ ) -> None:
331
+ _write_credentials(tmp_path, {"runtime_credential": "frt_cred"})
332
+ monkeypatch.setattr(daemon.settings, "DAEMON_API_TOKEN", "pat_personal_access_token")
333
+
334
+ with caplog.at_level(logging.WARNING, logger="daemon"):
335
+ runtime = daemon.RuntimeDaemon()
336
+
337
+ assert runtime._has_explicit_api_token is False
338
+ assert runtime._token_for_server(_ISSUER) == "frt_cred"
339
+ assert "PATs cannot register runtimes" in caplog.text
340
+
341
+
328
342
  # ── (f) daemon_credential_upgrade_required is not retryable ──────────────────
329
343
 
330
344
 
@@ -5,12 +5,15 @@ from types import SimpleNamespace
5
5
 
6
6
  import pytest
7
7
 
8
+ import forgexa_cli
8
9
  from forgexa_cli import autoupgrade, main
9
10
 
10
11
 
11
12
  @pytest.fixture(autouse=True)
12
13
  def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
13
14
  monkeypatch.setenv("HOME", str(tmp_path))
15
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
16
+ monkeypatch.setattr(autoupgrade, "_state_path", lambda: tmp_path / "upgrade-state.json")
14
17
 
15
18
 
16
19
  def _make_interactive(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -78,6 +81,20 @@ def test_regular_update_notice_still_works_without_pending_success(
78
81
  assert "Update available: forgexa 1.20.0 → 1.21.0" in err
79
82
 
80
83
 
84
+ def test_installed_version_uses_runtime_version_for_editable_install(
85
+ monkeypatch: pytest.MonkeyPatch,
86
+ ) -> None:
87
+ monkeypatch.setattr(main, "_cli_distribution", lambda: SimpleNamespace(version="1.21.7"))
88
+ monkeypatch.setattr(
89
+ main,
90
+ "_direct_url_metadata",
91
+ lambda _dist: {"dir_info": {"editable": True}},
92
+ )
93
+ monkeypatch.setattr(forgexa_cli, "__version__", "1.29.4")
94
+
95
+ assert main._installed_cli_version() == "1.29.4"
96
+
97
+
81
98
  # ── cmd_version / --status ──────────────────────────────────────────────────
82
99
 
83
100
  def test_version_prints_failure_hint_when_last_attempt_failed(
File without changes
File without changes