huly-cli 0.1.0__py3-none-any.whl

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.
huly_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
huly_cli/auth.py ADDED
@@ -0,0 +1,174 @@
1
+ """Authentication logic for Huly CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import httpx
8
+
9
+ from huly_cli.config import AuthCache, HulyConfig, load_auth, save_auth
10
+ from huly_cli.errors import AuthError
11
+
12
+
13
+ async def login(config: HulyConfig) -> AuthCache:
14
+ """Perform full auth flow and cache tokens.
15
+
16
+ Steps:
17
+ 1. POST /_accounts → login → account_token
18
+ 2. POST /_accounts with account_token → selectWorkspace → workspace_token + workspace_id
19
+ 3. POST /_accounts with account_token → getUserWorkspaces → workspace_uuid
20
+ 4. GET /_transactor/api/v1/account/{workspace_id} → account_id
21
+ 5. Verify with ping
22
+ """
23
+ if not config.email:
24
+ raise AuthError("Email is required for login. Set HULY_EMAIL env var.")
25
+ if not config.password:
26
+ raise AuthError("Password is required for login. Set HULY_PASSWORD env var.")
27
+ if not config.workspace:
28
+ raise AuthError("Workspace is required for login. Set HULY_WORKSPACE env var.")
29
+
30
+ accounts_url = f"{config.url}/_accounts"
31
+ transactor_base = f"{config.url}/_transactor"
32
+
33
+ async with httpx.AsyncClient(timeout=30.0) as http:
34
+ # Step 1: Login
35
+ resp = await http.post(
36
+ accounts_url,
37
+ json={"id": "1", "method": "login", "params": [config.email, config.password]},
38
+ )
39
+ resp.raise_for_status()
40
+ body = resp.json()
41
+ if "error" in body:
42
+ raise AuthError(f"Login failed: {body['error']}")
43
+ account_token: str = body["result"]["token"]
44
+
45
+ # Step 2: Select workspace
46
+ resp = await http.post(
47
+ accounts_url,
48
+ headers={"Authorization": f"Bearer {account_token}"},
49
+ json={
50
+ "id": "2",
51
+ "method": "selectWorkspace",
52
+ "params": [config.workspace, "external"],
53
+ },
54
+ )
55
+ resp.raise_for_status()
56
+ body = resp.json()
57
+ if "error" in body:
58
+ raise AuthError(f"Select workspace failed: {body['error']}")
59
+ ws_result = body["result"]
60
+ workspace_token: str = ws_result["token"]
61
+ workspace_id: str = ws_result["workspaceId"]
62
+
63
+ # Step 3: Get workspace UUID via getUserWorkspaces
64
+ resp = await http.post(
65
+ accounts_url,
66
+ headers={"Authorization": f"Bearer {account_token}"},
67
+ json={"id": "3", "method": "getUserWorkspaces", "params": []},
68
+ )
69
+ resp.raise_for_status()
70
+ body = resp.json()
71
+ if "error" in body:
72
+ raise AuthError(f"getUserWorkspaces failed: {body['error']}")
73
+ workspace_uuid = ""
74
+ for ws in body.get("result", []):
75
+ if ws.get("workspaceUrl") == config.workspace:
76
+ workspace_uuid = ws.get("uuid", "")
77
+ break
78
+ if not workspace_uuid:
79
+ raise AuthError(
80
+ f"Could not find UUID for workspace '{config.workspace}' in getUserWorkspaces response."
81
+ )
82
+
83
+ # Step 4: Get account ID
84
+ resp = await http.get(
85
+ f"{transactor_base}/api/v1/account/{workspace_id}",
86
+ headers={"Authorization": f"Bearer {workspace_token}"},
87
+ )
88
+ resp.raise_for_status()
89
+ account_data = resp.json()
90
+ # account endpoint returns the account object; _id is the account ID
91
+ account_id: str = account_data.get("_id", account_data.get("id", ""))
92
+
93
+ # Step 5: Ping to verify
94
+ ping_resp = await http.get(
95
+ f"{transactor_base}/api/v1/ping/{workspace_id}",
96
+ headers={"Authorization": f"Bearer {workspace_token}"},
97
+ )
98
+ ping_resp.raise_for_status()
99
+
100
+ auth = AuthCache(
101
+ account_token=account_token,
102
+ workspace_token=workspace_token,
103
+ workspace_id=workspace_id,
104
+ workspace_uuid=workspace_uuid,
105
+ email=config.email,
106
+ workspace_slug=config.workspace,
107
+ account_id=account_id,
108
+ cached_at=time.time(),
109
+ )
110
+ save_auth(auth)
111
+ return auth
112
+
113
+
114
+ async def _ping(config: HulyConfig, auth: AuthCache) -> bool:
115
+ """Ping the transactor to verify the cached token is still valid."""
116
+ transactor_base = f"{config.url}/_transactor"
117
+ try:
118
+ async with httpx.AsyncClient(timeout=10.0) as http:
119
+ resp = await http.get(
120
+ f"{transactor_base}/api/v1/ping/{auth.workspace_id}",
121
+ headers={"Authorization": f"Bearer {auth.workspace_token}"},
122
+ )
123
+ return resp.status_code == 200
124
+ except Exception:
125
+ return False
126
+
127
+
128
+ async def ensure_auth(config: HulyConfig) -> AuthCache:
129
+ """Return valid auth — use cache if fresh, otherwise re-login."""
130
+ cached = load_auth()
131
+ if cached is not None:
132
+ if await _ping(config, cached):
133
+ return cached
134
+ # Token stale — re-login if we have credentials
135
+ if not config.email or not config.password:
136
+ raise AuthError(
137
+ "Cached token is invalid and no credentials available to re-authenticate. "
138
+ "Run 'huly auth login'."
139
+ )
140
+ elif not config.email or not config.password:
141
+ raise AuthError("Not authenticated. Run 'huly auth login'.")
142
+
143
+ return await login(config)
144
+
145
+
146
+ async def check_auth_status(config: HulyConfig) -> dict:
147
+ """Return a dict describing current auth status."""
148
+ import time as time_mod
149
+
150
+ cached = load_auth()
151
+ if cached is None:
152
+ return {"authenticated": False, "email": None, "workspace": None, "token_age": None}
153
+
154
+ is_valid = await _ping(config, cached)
155
+ age_seconds = time_mod.time() - cached.cached_at
156
+ return {
157
+ "authenticated": is_valid,
158
+ "email": cached.email,
159
+ "workspace": cached.workspace_slug,
160
+ "workspace_id": cached.workspace_id,
161
+ "workspace_uuid": cached.workspace_uuid,
162
+ "token_age_seconds": int(age_seconds),
163
+ "token_age_human": _humanize_age(age_seconds),
164
+ }
165
+
166
+
167
+ def _humanize_age(seconds: float) -> str:
168
+ if seconds < 60:
169
+ return f"{int(seconds)}s"
170
+ if seconds < 3600:
171
+ return f"{int(seconds // 60)}m"
172
+ if seconds < 86400:
173
+ return f"{int(seconds // 3600)}h"
174
+ return f"{int(seconds // 86400)}d"
huly_cli/client.py ADDED
@@ -0,0 +1,319 @@
1
+ """HTTP client for Huly Transactor + Collaborator APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as json_mod
6
+ import time
7
+ import urllib.parse
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from huly_cli.config import AuthCache, HulyConfig
13
+ from huly_cli.errors import AuthError, RateLimitError, ServerError
14
+ from huly_cli.output import print_warning
15
+
16
+
17
+ class HulyClient:
18
+ def __init__(self, config: HulyConfig, auth: AuthCache) -> None:
19
+ self._config = config
20
+ self._auth = auth
21
+ self._transactor_base = f"{config.url}/_transactor"
22
+ self._http = httpx.AsyncClient(
23
+ base_url=self._transactor_base,
24
+ headers={"Authorization": f"Bearer {auth.workspace_token}"},
25
+ timeout=30.0,
26
+ )
27
+
28
+ async def find_all(
29
+ self,
30
+ class_id: str,
31
+ query: dict[str, Any] | None = None,
32
+ options: dict[str, Any] | None = None,
33
+ ) -> list[dict[str, Any]]:
34
+ """Query documents via POST /api/v1/find-all/{workspace_id}."""
35
+ query = query or {}
36
+ body = {
37
+ "_class": class_id,
38
+ "query": query,
39
+ "options": options or {},
40
+ }
41
+ resp = await self._http.post(
42
+ f"/api/v1/find-all/{self._auth.workspace_id}",
43
+ json=body,
44
+ )
45
+ data = self._handle_response(resp)
46
+ return self._normalize_find_all_result(class_id, query, data)
47
+
48
+ async def tx(self, transaction: dict[str, Any]) -> dict[str, Any]:
49
+ """Execute a transaction via POST /api/v1/tx/{workspace_id}."""
50
+ transaction = dict(transaction)
51
+ transaction.setdefault("modifiedBy", self._auth.account_id)
52
+ transaction.setdefault("modifiedOn", int(time.time() * 1000))
53
+ resp = await self._http.post(
54
+ f"/api/v1/tx/{self._auth.workspace_id}",
55
+ json=transaction,
56
+ )
57
+ return self._handle_response(resp)
58
+
59
+ async def ping(self) -> bool:
60
+ """GET /api/v1/ping/{workspace_id} — returns True if server responds OK."""
61
+ try:
62
+ resp = await self._http.get(f"/api/v1/ping/{self._auth.workspace_id}")
63
+ return resp.status_code == 200
64
+ except Exception:
65
+ return False
66
+
67
+ async def get_account(self) -> dict[str, Any]:
68
+ """GET /api/v1/account/{workspace_id} — current user account info."""
69
+ resp = await self._http.get(f"/api/v1/account/{self._auth.workspace_id}")
70
+ return self._handle_response(resp)
71
+
72
+ # ── Collaborator RPC ──────────────────────────────────────────────────────
73
+
74
+ async def get_content(
75
+ self, class_id: str, object_id: str, field: str, blob_ref: str
76
+ ) -> str | None:
77
+ """Fetch entity content ProseMirror JSON via Collaborator RPC.
78
+
79
+ Works for any entity class and field, e.g.:
80
+ - class_id="tracker:class:Issue", field="description"
81
+ - class_id="document:class:Document", field="content"
82
+ """
83
+ doc_id = self._build_doc_id(class_id, object_id, field)
84
+ url = f"{self._config.url}/_collaborator/rpc/{doc_id}"
85
+ try:
86
+ async with httpx.AsyncClient(timeout=15.0) as http:
87
+ resp = await http.post(
88
+ url,
89
+ headers={
90
+ "Authorization": f"Bearer {self._auth.workspace_token}",
91
+ "Content-Type": "application/json",
92
+ },
93
+ json={"method": "getContent", "payload": {"source": blob_ref}},
94
+ )
95
+ if resp.status_code != 200:
96
+ print_warning(f"getContent returned HTTP {resp.status_code}: {resp.text[:200]}")
97
+ return None
98
+ data = resp.json()
99
+ content = data.get("content", {}).get(field)
100
+ if content is None:
101
+ return None
102
+ if isinstance(content, (dict, list)):
103
+ return json_mod.dumps(content)
104
+ return str(content)
105
+ except Exception as e:
106
+ print_warning(f"getContent error: {e}")
107
+ return None
108
+
109
+ async def create_content(
110
+ self,
111
+ class_id: str,
112
+ object_id: str,
113
+ field: str,
114
+ markup_json: str,
115
+ *,
116
+ warn_on_error: bool = True,
117
+ ) -> str | None:
118
+ """Create entity content via Collaborator RPC and return its blob ref."""
119
+ doc_id = self._build_doc_id(class_id, object_id, field)
120
+ url = f"{self._config.url}/_collaborator/rpc/{doc_id}"
121
+ try:
122
+ async with httpx.AsyncClient(timeout=15.0) as http:
123
+ resp = await http.post(
124
+ url,
125
+ headers={
126
+ "Authorization": f"Bearer {self._auth.workspace_token}",
127
+ "Content-Type": "application/json",
128
+ },
129
+ json={
130
+ "method": "createContent",
131
+ "payload": {
132
+ "content": {field: markup_json},
133
+ },
134
+ },
135
+ )
136
+ if resp.status_code != 200:
137
+ if warn_on_error:
138
+ print_warning(
139
+ f"createContent failed (HTTP {resp.status_code}): {resp.text[:200]}"
140
+ )
141
+ return None
142
+ data = resp.json()
143
+ blob_ref = data.get("content", {}).get(field)
144
+ if blob_ref is None:
145
+ return None
146
+ return str(blob_ref)
147
+ except Exception as e:
148
+ if warn_on_error:
149
+ print_warning(f"createContent error: {e}")
150
+ return None
151
+
152
+ async def set_content(
153
+ self,
154
+ class_id: str,
155
+ object_id: str,
156
+ field: str,
157
+ blob_ref: str,
158
+ markup_json: str,
159
+ *,
160
+ warn_on_error: bool = True,
161
+ ) -> bool:
162
+ """Update entity content via Collaborator RPC.
163
+
164
+ Works for any entity class and field.
165
+ """
166
+ doc_id = self._build_doc_id(class_id, object_id, field)
167
+ url = f"{self._config.url}/_collaborator/rpc/{doc_id}"
168
+ try:
169
+ async with httpx.AsyncClient(timeout=15.0) as http:
170
+ resp = await http.post(
171
+ url,
172
+ headers={
173
+ "Authorization": f"Bearer {self._auth.workspace_token}",
174
+ "Content-Type": "application/json",
175
+ },
176
+ json={
177
+ "method": "updateContent",
178
+ "payload": {
179
+ "source": blob_ref,
180
+ "content": {field: markup_json},
181
+ },
182
+ },
183
+ )
184
+ if resp.status_code != 200:
185
+ if warn_on_error:
186
+ print_warning(
187
+ f"updateContent failed (HTTP {resp.status_code}): {resp.text[:200]}"
188
+ )
189
+ return False
190
+ return True
191
+ except Exception as e:
192
+ if warn_on_error:
193
+ print_warning(f"updateContent error: {e}")
194
+ return False
195
+
196
+ async def get_description(self, issue_id: str, blob_ref: str) -> str | None:
197
+ """Fetch issue description ProseMirror JSON via Collaborator RPC.
198
+
199
+ Thin wrapper around get_content for tracker:class:Issue / description.
200
+ """
201
+ return await self.get_content("tracker:class:Issue", issue_id, "description", blob_ref)
202
+
203
+ async def create_description(
204
+ self,
205
+ issue_id: str,
206
+ markup_json: str,
207
+ *,
208
+ warn_on_error: bool = True,
209
+ ) -> str | None:
210
+ """Create issue description content and return the blob ref."""
211
+ return await self.create_content(
212
+ "tracker:class:Issue",
213
+ issue_id,
214
+ "description",
215
+ markup_json,
216
+ warn_on_error=warn_on_error,
217
+ )
218
+
219
+ async def set_description(
220
+ self,
221
+ issue_id: str,
222
+ blob_ref: str,
223
+ markup_json: str,
224
+ *,
225
+ warn_on_error: bool = True,
226
+ ) -> bool:
227
+ """Update issue description via Collaborator RPC.
228
+
229
+ Thin wrapper around set_content for tracker:class:Issue / description.
230
+ """
231
+ return await self.set_content(
232
+ "tracker:class:Issue",
233
+ issue_id,
234
+ "description",
235
+ blob_ref,
236
+ markup_json,
237
+ warn_on_error=warn_on_error,
238
+ )
239
+
240
+ def _build_doc_id(self, class_id: str, object_id: str, field: str) -> str:
241
+ """URL-encode the collaborator document ID for any entity class and field."""
242
+ raw = f"{self._auth.workspace_uuid}|{class_id}|{object_id}|{field}"
243
+ return urllib.parse.quote(raw, safe="")
244
+
245
+ # ── Response handling ─────────────────────────────────────────────────────
246
+
247
+ def _handle_response(self, resp: httpx.Response) -> dict[str, Any]:
248
+ if resp.status_code in (401, 403):
249
+ raise AuthError(
250
+ f"Authentication failed (HTTP {resp.status_code}). Run 'huly auth login'."
251
+ )
252
+ if resp.status_code == 429:
253
+ retry_after = float(
254
+ resp.headers.get("Retry-After") or resp.headers.get("Retry-After-ms", 0)
255
+ )
256
+ if retry_after > 1000:
257
+ retry_after /= 1000 # convert ms to seconds
258
+ raise RateLimitError("Rate limit exceeded.", retry_after=retry_after)
259
+ if resp.status_code >= 500:
260
+ raise ServerError(f"Server error (HTTP {resp.status_code}): {resp.text[:200]}")
261
+ resp.raise_for_status()
262
+ try:
263
+ return resp.json()
264
+ except Exception:
265
+ return {}
266
+
267
+ def _normalize_find_all_result(
268
+ self,
269
+ class_id: str,
270
+ query: dict[str, Any],
271
+ data: dict[str, Any] | list[dict[str, Any]],
272
+ ) -> list[dict[str, Any]]:
273
+ """Mirror the upstream REST client normalization for `find-all`.
274
+
275
+ The platform client restores scalar query values that may be omitted in
276
+ filtered results and resolves lookup references using the response's
277
+ `lookupMap`.
278
+ """
279
+ if isinstance(data, dict):
280
+ lookup_map = data.pop("lookupMap", None)
281
+ rows = data.get("value", [])
282
+ else:
283
+ lookup_map = None
284
+ rows = data
285
+
286
+ if not isinstance(rows, list):
287
+ return []
288
+
289
+ if isinstance(lookup_map, dict):
290
+ for row in rows:
291
+ if not isinstance(row, dict):
292
+ continue
293
+ lookup = row.get("$lookup")
294
+ if not isinstance(lookup, dict):
295
+ continue
296
+ for key, value in list(lookup.items()):
297
+ if isinstance(value, list):
298
+ lookup[key] = [lookup_map.get(item) for item in value]
299
+ else:
300
+ lookup[key] = lookup_map.get(value)
301
+
302
+ for row in rows:
303
+ if not isinstance(row, dict):
304
+ continue
305
+ if row.get("_class") is None:
306
+ row["_class"] = class_id
307
+ for key, value in query.items():
308
+ if isinstance(value, (str, int, bool)) and row.get(key) is None:
309
+ row[key] = value
310
+
311
+ return rows
312
+
313
+ # ── Context manager ───────────────────────────────────────────────────────
314
+
315
+ async def __aenter__(self) -> HulyClient:
316
+ return self
317
+
318
+ async def __aexit__(self, *args: Any) -> None:
319
+ await self._http.aclose()
File without changes
@@ -0,0 +1,94 @@
1
+ """Auth commands: login, status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from huly_cli.auth import check_auth_status, login
12
+ from huly_cli.config import load_config, save_config
13
+ from huly_cli.errors import AuthError
14
+ from huly_cli.output import print_error, print_item, print_success
15
+
16
+ app = typer.Typer(help="Authentication commands.", no_args_is_help=True)
17
+
18
+
19
+ @app.command("login")
20
+ def auth_login(
21
+ ctx: typer.Context,
22
+ email: Annotated[
23
+ str | None,
24
+ typer.Option("--email", "-e", help="Huly account email.", envvar="HULY_EMAIL"),
25
+ ] = None,
26
+ password: Annotated[
27
+ str | None,
28
+ typer.Option(
29
+ "--password",
30
+ "-p",
31
+ help="Huly account password.",
32
+ envvar="HULY_PASSWORD",
33
+ ),
34
+ ] = None,
35
+ ) -> None:
36
+ """Log in to Huly and cache authentication tokens."""
37
+ overrides: dict = ctx.obj or {}
38
+ config = load_config(
39
+ url_override=overrides.get("url"),
40
+ workspace_override=overrides.get("workspace"),
41
+ )
42
+
43
+ # Resolve email
44
+ resolved_email = email or config.email
45
+ if not resolved_email:
46
+ if sys.stdin.isatty():
47
+ resolved_email = typer.prompt("Email")
48
+ else:
49
+ raise AuthError("Email required. Set --email or HULY_EMAIL env var.")
50
+
51
+ # Resolve password
52
+ resolved_password = password or config.password
53
+ if not resolved_password:
54
+ if sys.stdin.isatty():
55
+ resolved_password = typer.prompt("Password", hide_input=True)
56
+ else:
57
+ raise AuthError("Password required. Set --password or HULY_PASSWORD env var.")
58
+
59
+ # Resolve workspace
60
+ if not config.workspace:
61
+ if sys.stdin.isatty():
62
+ config.workspace = typer.prompt("Workspace slug", default="efs")
63
+ else:
64
+ raise AuthError("Workspace required. Set HULY_WORKSPACE env var.")
65
+
66
+ config.email = resolved_email
67
+ config.password = resolved_password
68
+
69
+ try:
70
+ auth = asyncio.run(login(config))
71
+ except AuthError as e:
72
+ print_error(e.message)
73
+ raise typer.Exit(2) from e
74
+ except Exception as e:
75
+ print_error(f"Login failed: {e}")
76
+ raise typer.Exit(1) from e
77
+
78
+ save_config(config)
79
+ print_success(
80
+ f"Logged in as [bold]{auth.email}[/bold] to workspace [bold]{auth.workspace_slug}[/bold]"
81
+ )
82
+
83
+
84
+ @app.command("status")
85
+ def auth_status(ctx: typer.Context) -> None:
86
+ """Show current authentication status."""
87
+ overrides: dict = ctx.obj or {}
88
+ config = load_config(
89
+ url_override=overrides.get("url"),
90
+ workspace_override=overrides.get("workspace"),
91
+ )
92
+
93
+ result = asyncio.run(check_auth_status(config))
94
+ print_item(result, title="Auth Status")