forgexa-cli 1.62.3__tar.gz → 1.62.4__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.62.3 → forgexa_cli-1.62.4}/PKG-INFO +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/daemon.py +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/main.py +77 -60
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/pyproject.toml +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_local_bind_commands.py +111 -2
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/README.md +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/setup.cfg +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.62.
|
|
2
|
+
__version__ = "1.62.4"
|
|
@@ -1196,7 +1196,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1196
1196
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1197
1197
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1198
1198
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1199
|
-
DAEMON_VERSION = "1.62.
|
|
1199
|
+
DAEMON_VERSION = "1.62.4"
|
|
1200
1200
|
|
|
1201
1201
|
|
|
1202
1202
|
def _detect_client_type() -> str:
|
|
@@ -3266,7 +3266,8 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
3266
3266
|
)
|
|
3267
3267
|
break
|
|
3268
3268
|
|
|
3269
|
-
|
|
3269
|
+
created_binding = binding is None
|
|
3270
|
+
if created_binding:
|
|
3270
3271
|
binding = _post(
|
|
3271
3272
|
f"/projects/{args.project}/local-workspace-bindings",
|
|
3272
3273
|
{"runtime_id": runtime["id"], "multi_binding_capable": True},
|
|
@@ -3275,69 +3276,85 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
3275
3276
|
if not binding_id:
|
|
3276
3277
|
print("Error: failed to create local workspace binding", file=sys.stderr)
|
|
3277
3278
|
sys.exit(1)
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
_delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
|
|
3282
|
-
except Exception:
|
|
3283
|
-
pass
|
|
3284
|
-
print(
|
|
3285
|
-
"Error: this folder's Git remote does not match the selected Forgexa project's repository "
|
|
3286
|
-
f"(project: {expected_identity}, this folder: {identity}). "
|
|
3287
|
-
"Open the matching folder or update the project's repository URL.",
|
|
3288
|
-
file=sys.stderr,
|
|
3289
|
-
)
|
|
3290
|
-
sys.exit(1)
|
|
3291
|
-
|
|
3292
|
-
# A folder can only ever be validly bound to one binding_id at a time -
|
|
3293
|
-
# drop any other (necessarily stale) entry already pointing at this same
|
|
3294
|
-
# path first. Without this, a stale entry left behind by an earlier
|
|
3295
|
-
# attempt (e.g. a re-registered runtime after a reinstall, or a
|
|
3296
|
-
# verification that timed out client-side) can linger alongside the new
|
|
3297
|
-
# one, and agent_host.py's `_match_local_binding_id` (a plain path scan
|
|
3298
|
-
# with no tie-breaking) can resolve to the wrong, orphaned binding_id
|
|
3299
|
-
# instead of the one just verified. The whole read-modify-write runs
|
|
3300
|
-
# under the cross-process mapping lock shared with the Desktop writer.
|
|
3301
|
-
with local_workspaces_mapping_lock(local_path):
|
|
3302
|
-
mapping = load_local_workspaces_mapping()
|
|
3303
|
-
mapping = {
|
|
3304
|
-
key: entry
|
|
3305
|
-
for key, entry in mapping.items()
|
|
3306
|
-
if key == binding_id or entry.get("local_path") != local_path_str
|
|
3307
|
-
}
|
|
3308
|
-
mapping[binding_id] = {
|
|
3309
|
-
"project_id": args.project,
|
|
3310
|
-
"user_id": user_id,
|
|
3311
|
-
"runtime_id": runtime["id"],
|
|
3312
|
-
"repo_identity": identity,
|
|
3313
|
-
"local_path": str(local_path),
|
|
3314
|
-
"share_agent_output": True,
|
|
3315
|
-
}
|
|
3316
|
-
save_local_workspaces_mapping(mapping)
|
|
3317
|
-
|
|
3318
|
-
_post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
|
|
3319
|
-
deadline = time.monotonic() + 35
|
|
3320
|
-
while True:
|
|
3321
|
-
verified = _get(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
|
|
3322
|
-
status = verified.get("status", "unknown") if isinstance(verified, dict) else "unknown"
|
|
3323
|
-
if status == "active":
|
|
3324
|
-
print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
|
|
3325
|
-
break
|
|
3326
|
-
if status == "stale":
|
|
3327
|
-
print(
|
|
3328
|
-
f"Local daemon could not verify binding {binding_id[:8]}: "
|
|
3329
|
-
f"{verified.get('validation_error') or 'unknown validation failure'}",
|
|
3330
|
-
file=sys.stderr,
|
|
3331
|
-
)
|
|
3332
|
-
sys.exit(1)
|
|
3333
|
-
if time.monotonic() >= deadline:
|
|
3279
|
+
try:
|
|
3280
|
+
expected_identity = binding.get("repo_identity")
|
|
3281
|
+
if isinstance(expected_identity, str) and expected_identity != identity:
|
|
3334
3282
|
print(
|
|
3335
|
-
|
|
3336
|
-
"
|
|
3283
|
+
"Error: this folder's Git remote does not match the selected Forgexa project's repository "
|
|
3284
|
+
f"(project: {expected_identity}, this folder: {identity}). "
|
|
3285
|
+
"Open the matching folder or update the project's repository URL.",
|
|
3337
3286
|
file=sys.stderr,
|
|
3338
3287
|
)
|
|
3339
3288
|
sys.exit(1)
|
|
3340
|
-
|
|
3289
|
+
if not created_binding and binding.get("status") == "active":
|
|
3290
|
+
print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
|
|
3291
|
+
return
|
|
3292
|
+
|
|
3293
|
+
# A folder can only ever be validly bound to one binding_id at a time -
|
|
3294
|
+
# drop any other (necessarily stale) entry already pointing at this same
|
|
3295
|
+
# path first. Without this, a stale entry left behind by an earlier
|
|
3296
|
+
# attempt (e.g. a re-registered runtime after a reinstall, or a
|
|
3297
|
+
# verification that timed out client-side) can linger alongside the new
|
|
3298
|
+
# one, and agent_host.py's `_match_local_binding_id` (a plain path scan
|
|
3299
|
+
# with no tie-breaking) can resolve to the wrong, orphaned binding_id
|
|
3300
|
+
# instead of the one just verified. The whole read-modify-write runs
|
|
3301
|
+
# under the cross-process mapping lock shared with the Desktop writer.
|
|
3302
|
+
with local_workspaces_mapping_lock(local_path):
|
|
3303
|
+
mapping = load_local_workspaces_mapping()
|
|
3304
|
+
share_agent_output = mapping.get(binding_id, {}).get("share_agent_output") is not False
|
|
3305
|
+
mapping = {
|
|
3306
|
+
key: entry
|
|
3307
|
+
for key, entry in mapping.items()
|
|
3308
|
+
if key == binding_id
|
|
3309
|
+
or Path(str(entry.get("local_path") or "")).expanduser().resolve() != local_path
|
|
3310
|
+
}
|
|
3311
|
+
mapping[binding_id] = {
|
|
3312
|
+
"project_id": args.project,
|
|
3313
|
+
"user_id": user_id,
|
|
3314
|
+
"runtime_id": runtime["id"],
|
|
3315
|
+
"repo_identity": identity,
|
|
3316
|
+
"local_path": str(local_path),
|
|
3317
|
+
"share_agent_output": share_agent_output,
|
|
3318
|
+
}
|
|
3319
|
+
save_local_workspaces_mapping(mapping)
|
|
3320
|
+
|
|
3321
|
+
_post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
|
|
3322
|
+
deadline = time.monotonic() + 35
|
|
3323
|
+
while True:
|
|
3324
|
+
verified = _get(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
|
|
3325
|
+
status = verified.get("status", "unknown") if isinstance(verified, dict) else "unknown"
|
|
3326
|
+
if status == "active":
|
|
3327
|
+
print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
|
|
3328
|
+
break
|
|
3329
|
+
if status == "revoked":
|
|
3330
|
+
print(f"Local workspace binding {binding_id[:8]} was revoked; bind this folder again.", file=sys.stderr)
|
|
3331
|
+
sys.exit(1)
|
|
3332
|
+
if status == "stale":
|
|
3333
|
+
print(
|
|
3334
|
+
f"Local daemon could not verify binding {binding_id[:8]}: "
|
|
3335
|
+
f"{verified.get('validation_error') or 'unknown validation failure'}",
|
|
3336
|
+
file=sys.stderr,
|
|
3337
|
+
)
|
|
3338
|
+
sys.exit(1)
|
|
3339
|
+
if time.monotonic() >= deadline:
|
|
3340
|
+
print(
|
|
3341
|
+
f"Timed out waiting for the daemon to verify binding {binding_id[:8]}. "
|
|
3342
|
+
"Restart or update the daemon and try again.",
|
|
3343
|
+
file=sys.stderr,
|
|
3344
|
+
)
|
|
3345
|
+
sys.exit(1)
|
|
3346
|
+
time.sleep(1)
|
|
3347
|
+
except (Exception, SystemExit, KeyboardInterrupt):
|
|
3348
|
+
if created_binding:
|
|
3349
|
+
try:
|
|
3350
|
+
_delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
|
|
3351
|
+
with local_workspaces_mapping_lock(local_path):
|
|
3352
|
+
mapping = load_local_workspaces_mapping()
|
|
3353
|
+
if mapping.pop(binding_id, None) is not None:
|
|
3354
|
+
save_local_workspaces_mapping(mapping)
|
|
3355
|
+
except (Exception, SystemExit):
|
|
3356
|
+
pass
|
|
3357
|
+
raise
|
|
3341
3358
|
|
|
3342
3359
|
|
|
3343
3360
|
def cmd_project_unbind_local(args: argparse.Namespace) -> None:
|
|
@@ -106,7 +106,10 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
@pytest.mark.parametrize("path_alias", [False, True])
|
|
110
|
+
def test_bind_local_replaces_stale_entry_for_same_local_path(
|
|
111
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path_alias: bool,
|
|
112
|
+
) -> None:
|
|
110
113
|
"""A stale entry from an earlier attempt (e.g. before a daemon/runtime
|
|
111
114
|
reinstall) must not linger alongside a freshly-verified one for the same
|
|
112
115
|
folder - agent_host.py's _match_local_binding_id does a plain path scan
|
|
@@ -125,7 +128,7 @@ def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, mon
|
|
|
125
128
|
"user_id": "user-1",
|
|
126
129
|
"runtime_id": "runtime-old",
|
|
127
130
|
"repo_identity": "github.com/acme/widgets",
|
|
128
|
-
"local_path": str(repo.resolve()),
|
|
131
|
+
"local_path": str(repo.resolve()) + ("/." if path_alias else ""),
|
|
129
132
|
},
|
|
130
133
|
}))
|
|
131
134
|
|
|
@@ -165,6 +168,112 @@ def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, mon
|
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
|
|
171
|
+
@pytest.mark.parametrize("stage", ["mapping", "verification", "stale", "revoked", "timeout", "rollback"])
|
|
172
|
+
def test_bind_local_rolls_back_only_new_binding_on_failure(
|
|
173
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stage: str,
|
|
174
|
+
) -> None:
|
|
175
|
+
from forgexa_cli import _local_bind
|
|
176
|
+
|
|
177
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
178
|
+
repo = tmp_path / "widgets"
|
|
179
|
+
_init_git_repo_with_remote(repo)
|
|
180
|
+
mapping_path = tmp_path / ".forgexa" / "local-workspaces.json"
|
|
181
|
+
mapping_path.parent.mkdir()
|
|
182
|
+
original = {"binding-other": {"project_id": "project-1", "local_path": str(tmp_path / "other")}}
|
|
183
|
+
mapping_path.write_text(json.dumps(original))
|
|
184
|
+
monkeypatch.setattr(main, "_get_runtimes", lambda: [{"id": "runtime-1", "pool_type": "personal"}])
|
|
185
|
+
deletes = []
|
|
186
|
+
|
|
187
|
+
def fake_get(path: str):
|
|
188
|
+
if path == "/auth/me":
|
|
189
|
+
return {"id": "user-1"}
|
|
190
|
+
return {"id": "binding-new", "status": stage if stage in ("stale", "revoked") else "pending"}
|
|
191
|
+
|
|
192
|
+
def fake_post(path: str, data=None):
|
|
193
|
+
if path.endswith("/local-workspace-bindings"):
|
|
194
|
+
return {"id": "binding-new", "status": "pending", "repo_identity": "github.com/acme/widgets"}
|
|
195
|
+
if stage in ("verification", "rollback"):
|
|
196
|
+
raise RuntimeError("Verification unavailable")
|
|
197
|
+
return {"status": "pending"}
|
|
198
|
+
|
|
199
|
+
def fake_delete(path: str):
|
|
200
|
+
deletes.append(path)
|
|
201
|
+
if stage == "rollback":
|
|
202
|
+
raise SystemExit(1)
|
|
203
|
+
|
|
204
|
+
monkeypatch.setattr(main, "_get", fake_get)
|
|
205
|
+
monkeypatch.setattr(main, "_post", fake_post)
|
|
206
|
+
monkeypatch.setattr(main, "_delete", fake_delete)
|
|
207
|
+
clock = iter([0, 36])
|
|
208
|
+
monkeypatch.setattr(main, "time", SimpleNamespace(
|
|
209
|
+
monotonic=lambda: next(clock, 36),
|
|
210
|
+
sleep=lambda duration: pytest.fail("A terminal failure must not keep polling"),
|
|
211
|
+
))
|
|
212
|
+
if stage == "mapping":
|
|
213
|
+
def fail_save(mapping):
|
|
214
|
+
raise RuntimeError("Mapping unavailable")
|
|
215
|
+
monkeypatch.setattr(_local_bind, "save_local_workspaces_mapping", fail_save)
|
|
216
|
+
|
|
217
|
+
args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
|
|
218
|
+
with pytest.raises((SystemExit, RuntimeError)):
|
|
219
|
+
main.cmd_project_bind_local(args)
|
|
220
|
+
|
|
221
|
+
assert deletes == ["/projects/project-1/local-workspace-bindings/binding-new"]
|
|
222
|
+
mapping = json.loads(mapping_path.read_text())
|
|
223
|
+
assert mapping["binding-other"] == original["binding-other"]
|
|
224
|
+
assert ("binding-new" in mapping) == (stage == "rollback")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.parametrize("status", ["active", "stale", "repository_mismatch"])
|
|
228
|
+
def test_bind_local_preserves_existing_binding_and_output_sharing(
|
|
229
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str,
|
|
230
|
+
) -> None:
|
|
231
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
232
|
+
repo = tmp_path / "widgets"
|
|
233
|
+
_init_git_repo_with_remote(repo)
|
|
234
|
+
mapping_path = tmp_path / ".forgexa" / "local-workspaces.json"
|
|
235
|
+
mapping_path.parent.mkdir()
|
|
236
|
+
entry = {
|
|
237
|
+
"project_id": "project-1", "user_id": "user-1", "runtime_id": "runtime-1",
|
|
238
|
+
"repo_identity": "github.com/acme/widgets", "local_path": str(repo),
|
|
239
|
+
"share_agent_output": False,
|
|
240
|
+
}
|
|
241
|
+
mapping_path.write_text(json.dumps({"binding-existing": entry}))
|
|
242
|
+
monkeypatch.setattr(main, "_get_runtimes", lambda: [{"id": "runtime-1", "pool_type": "personal"}])
|
|
243
|
+
posts = []
|
|
244
|
+
deletes = []
|
|
245
|
+
|
|
246
|
+
def fake_get(path: str):
|
|
247
|
+
if path == "/auth/me":
|
|
248
|
+
return {"id": "user-1"}
|
|
249
|
+
if path.endswith("/local-workspace-bindings"):
|
|
250
|
+
return [{
|
|
251
|
+
"id": "binding-existing", "status": "active" if status == "repository_mismatch" else status,
|
|
252
|
+
"repo_identity": "github.com/acme/other" if status == "repository_mismatch" else "github.com/acme/widgets",
|
|
253
|
+
"lease_held": status == "active",
|
|
254
|
+
}]
|
|
255
|
+
return {"id": "binding-existing", "status": "active"}
|
|
256
|
+
|
|
257
|
+
def fake_post(path: str, data=None):
|
|
258
|
+
posts.append(path)
|
|
259
|
+
assert path.endswith("/verify")
|
|
260
|
+
return {"status": "pending"}
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(main, "_get", fake_get)
|
|
263
|
+
monkeypatch.setattr(main, "_post", fake_post)
|
|
264
|
+
monkeypatch.setattr(main, "_delete", lambda path: deletes.append(path))
|
|
265
|
+
args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
|
|
266
|
+
if status == "repository_mismatch":
|
|
267
|
+
with pytest.raises(SystemExit):
|
|
268
|
+
main.cmd_project_bind_local(args)
|
|
269
|
+
else:
|
|
270
|
+
main.cmd_project_bind_local(args)
|
|
271
|
+
|
|
272
|
+
assert posts == (["/projects/project-1/local-workspace-bindings/binding-existing/verify"] if status == "stale" else [])
|
|
273
|
+
assert deletes == []
|
|
274
|
+
assert json.loads(mapping_path.read_text()) == {"binding-existing": entry}
|
|
275
|
+
|
|
276
|
+
|
|
168
277
|
def test_bind_local_rejects_non_git_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
169
278
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
170
279
|
plain_dir = tmp_path / "not-a-repo"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|