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.
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/PKG-INFO +1 -1
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/agent_core.py +6 -5
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/daemon.py +43 -8
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/main.py +247 -17
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/pyproject.toml +1 -1
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_auth_and_runtime_commands.py +178 -2
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_expiry_warnings_and_revoke.py +13 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_runtime_credentials.py +14 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_upgrade_observability.py +17 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/README.md +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/setup.cfg +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.29.4 → forgexa_cli-1.30.0}/tests/test_silent_install.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
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
|
-
"""
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return {"start_new_session": True}
|
|
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.
|
|
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.
|
|
7782
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
1565
|
-
print(f"
|
|
1566
|
-
print(
|
|
1567
|
-
|
|
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
|
-
|
|
1796
|
-
|
|
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
|
|
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:
|
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|