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.
Files changed (29) hide show
  1. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/PKG-INFO +1 -1
  2. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/daemon.py +1 -1
  4. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/main.py +77 -60
  5. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/pyproject.toml +1 -1
  7. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_local_bind_commands.py +111 -2
  8. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/README.md +0 -0
  9. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/_build_config.py +0 -0
  10. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/_local_bind.py +0 -0
  11. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/agent_core.py +0 -0
  12. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/autoupgrade.py +0 -0
  13. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/runtime_evidence.py +0 -0
  15. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli/zcode_protocol.py +0 -0
  16. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  17. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  18. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  19. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/requires.txt +0 -0
  20. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  21. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/setup.cfg +0 -0
  22. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_auth_and_runtime_commands.py +0 -0
  23. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_autoupgrade.py +0 -0
  24. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_check_command.py +0 -0
  25. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
  26. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.62.3 → forgexa_cli-1.62.4}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.62.3
3
+ Version: 1.62.4
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.62.3"
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.3"
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
- if binding is None:
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
- expected_identity = binding.get("repo_identity")
3279
- if isinstance(expected_identity, str) and expected_identity != identity:
3280
- try:
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
- f"Timed out waiting for the daemon to verify binding {binding_id[:8]}. "
3336
- "Restart or update the daemon and try again.",
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
- time.sleep(1)
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.62.3
3
+ Version: 1.62.4
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.62.3"
3
+ version = "1.62.4"
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"
@@ -106,7 +106,10 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
106
106
  }
107
107
 
108
108
 
109
- def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
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