pipefy-cli 0.3.0a1__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.
Files changed (40) hide show
  1. pipefy_cli/__init__.py +7 -0
  2. pipefy_cli/_docs.py +3 -0
  3. pipefy_cli/auth.py +225 -0
  4. pipefy_cli/commands/__init__.py +5 -0
  5. pipefy_cli/commands/_common.py +359 -0
  6. pipefy_cli/commands/agent.py +358 -0
  7. pipefy_cli/commands/ai_automation.py +318 -0
  8. pipefy_cli/commands/attachment.py +122 -0
  9. pipefy_cli/commands/audit.py +63 -0
  10. pipefy_cli/commands/auth.py +519 -0
  11. pipefy_cli/commands/automation.py +529 -0
  12. pipefy_cli/commands/card.py +439 -0
  13. pipefy_cli/commands/email.py +169 -0
  14. pipefy_cli/commands/export.py +61 -0
  15. pipefy_cli/commands/field.py +113 -0
  16. pipefy_cli/commands/field_condition.py +201 -0
  17. pipefy_cli/commands/graphql.py +110 -0
  18. pipefy_cli/commands/introspect.py +110 -0
  19. pipefy_cli/commands/label.py +109 -0
  20. pipefy_cli/commands/member.py +126 -0
  21. pipefy_cli/commands/org.py +60 -0
  22. pipefy_cli/commands/phase.py +264 -0
  23. pipefy_cli/commands/pipe.py +167 -0
  24. pipefy_cli/commands/portal.py +871 -0
  25. pipefy_cli/commands/record.py +219 -0
  26. pipefy_cli/commands/relation.py +173 -0
  27. pipefy_cli/commands/report_org.py +241 -0
  28. pipefy_cli/commands/report_pipe.py +290 -0
  29. pipefy_cli/commands/table.py +151 -0
  30. pipefy_cli/commands/usage.py +100 -0
  31. pipefy_cli/commands/webhook.py +141 -0
  32. pipefy_cli/main.py +132 -0
  33. pipefy_cli/output/__init__.py +8 -0
  34. pipefy_cli/output/json_renderer.py +24 -0
  35. pipefy_cli/output/rich_renderer.py +81 -0
  36. pipefy_cli/settings.py +66 -0
  37. pipefy_cli-0.3.0a1.dist-info/METADATA +104 -0
  38. pipefy_cli-0.3.0a1.dist-info/RECORD +40 -0
  39. pipefy_cli-0.3.0a1.dist-info/WHEEL +4 -0
  40. pipefy_cli-0.3.0a1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,122 @@
1
+ """Upload attachments to card or table record fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from pipefy_sdk import (
9
+ Attachment,
10
+ AttachmentUploadError,
11
+ CardTarget,
12
+ PipefyClient,
13
+ TableRecordTarget,
14
+ )
15
+
16
+ from pipefy_cli.commands._common import run_cli_command
17
+
18
+ attachment_app = typer.Typer(
19
+ help="Attachment uploads (presigned URL + field update).", no_args_is_help=True
20
+ )
21
+
22
+
23
+ @attachment_app.command("upload")
24
+ def attachment_upload(
25
+ ctx: typer.Context,
26
+ file: Path = typer.Option(
27
+ ...,
28
+ "--file",
29
+ "-f",
30
+ help="Local file path to upload. Supports ~ expansion.",
31
+ ),
32
+ card: str | None = typer.Option(
33
+ None,
34
+ "--card",
35
+ help="Target card id (mutually exclusive with --record).",
36
+ ),
37
+ record: str | None = typer.Option(
38
+ None,
39
+ "--record",
40
+ help="Target table record id (mutually exclusive with --card).",
41
+ ),
42
+ organization: str = typer.Option(
43
+ ...,
44
+ "--organization",
45
+ "--org",
46
+ help="Organization id (required for presigned URL).",
47
+ ),
48
+ field: str = typer.Option(
49
+ ...,
50
+ "--field",
51
+ help="Attachment field slug on the card or table record.",
52
+ ),
53
+ content_type: str | None = typer.Option(
54
+ None,
55
+ "--content-type",
56
+ help="Optional MIME type (defaults from file name).",
57
+ ),
58
+ json_out: bool = typer.Option(
59
+ False,
60
+ "--json",
61
+ "-j",
62
+ help="Print machine-readable JSON to stdout.",
63
+ ),
64
+ ) -> None:
65
+ """Upload a local file to a card or table record attachment field.
66
+
67
+ Uses the same SDK flow as MCP ``upload_attachment_to_card`` /
68
+ ``upload_attachment_to_table_record``: presigned URL, S3 PUT, then field update.
69
+ """
70
+ if (card is None) == (record is None):
71
+ raise typer.BadParameter("Provide exactly one of --card or --record.")
72
+
73
+ org_str = str(organization).strip()
74
+ field_str = str(field).strip()
75
+ if not org_str or not field_str:
76
+ raise typer.BadParameter("--organization and --field must be non-empty.")
77
+
78
+ card_id = str(card).strip() if card is not None else None
79
+ record_id = str(record).strip() if record is not None else None
80
+
81
+ attachment = Attachment(path=file, content_type=content_type)
82
+ if card_id is not None:
83
+ target = CardTarget(card_id=card_id, field_id=field_str)
84
+ else:
85
+ target = TableRecordTarget(table_record_id=record_id or "", field_id=field_str)
86
+
87
+ async def factory(client: PipefyClient) -> dict[str, object]:
88
+ try:
89
+ result = await client.upload_attachment(
90
+ attachment, organization_id=org_str, target=target
91
+ )
92
+ except AttachmentUploadError as exc:
93
+ if exc.step == "file_read":
94
+ message = str(exc.__cause__) if exc.__cause__ else str(exc)
95
+ raise typer.BadParameter(message) from exc
96
+ out: dict[str, object] = {
97
+ "success": False,
98
+ "step": exc.step,
99
+ "message": str(exc),
100
+ }
101
+ if exc.body_snippet is not None:
102
+ out["body_snippet"] = exc.body_snippet
103
+ return out
104
+
105
+ target_payload: dict[str, object]
106
+ if isinstance(target, CardTarget):
107
+ target_payload = {"card_id": target.card_id}
108
+ else:
109
+ target_payload = {"table_record_id": target.table_record_id}
110
+
111
+ return {
112
+ "success": True,
113
+ "message": "Attachment uploaded.",
114
+ "file_name": result["file_name"],
115
+ "content_type": result["content_type"],
116
+ "file_size": result["file_size"],
117
+ "field_id": result["field_id"],
118
+ "download_url": result["download_url"],
119
+ **target_payload,
120
+ }
121
+
122
+ run_cli_command(ctx, json_out, factory)
@@ -0,0 +1,63 @@
1
+ """Pipe audit log export (async job)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TextIO
8
+
9
+ import typer
10
+
11
+ from pipefy_cli.commands._common import run_pipefy_client_coroutine
12
+ from pipefy_cli.output import render_json, render_rich
13
+
14
+ audit_app = typer.Typer(help="Pipe audit log exports.", no_args_is_help=True)
15
+
16
+
17
+ def _open_output(path: Path) -> TextIO:
18
+ """Open a UTF-8 text file for writing (parent dirs created)."""
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ return path.open("w", encoding="utf-8")
21
+
22
+
23
+ @audit_app.command("export")
24
+ def audit_export(
25
+ ctx: typer.Context,
26
+ pipe: str = typer.Option(..., "--pipe", help="Pipe UUID for the audit export."),
27
+ search: str | None = typer.Option(
28
+ None,
29
+ "--search",
30
+ help="Optional search term filter passed to the API.",
31
+ ),
32
+ output: Path | None = typer.Option(
33
+ None,
34
+ "--output",
35
+ "-o",
36
+ help="Write JSON response to this path instead of stdout.",
37
+ ),
38
+ json_out: bool = typer.Option(
39
+ False,
40
+ "--json",
41
+ "-j",
42
+ help="When using stdout, print JSON instead of Rich (file output is always JSON).",
43
+ ),
44
+ ) -> None:
45
+ """Queue a pipe audit log export (``export_pipe_audit_logs``).
46
+
47
+ The GraphQL API returns only ``success``; Pipefy delivers the export outside this mutation
48
+ (no CSV stream in the SDK response).
49
+ """
50
+ payload = run_pipefy_client_coroutine(
51
+ ctx,
52
+ lambda c: c.export_pipe_audit_logs(pipe, search_term=search),
53
+ )
54
+
55
+ if output is not None:
56
+ with _open_output(output) as fh:
57
+ print(json.dumps(payload, indent=2), file=fh, flush=True)
58
+ return
59
+
60
+ if json_out:
61
+ render_json(payload)
62
+ else:
63
+ render_rich(payload)
@@ -0,0 +1,519 @@
1
+ """Interactive user authentication via OAuth 2.0 Authorization Code + PKCE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import webbrowser
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Literal
11
+
12
+ import typer
13
+ from gql.transport.exceptions import (
14
+ TransportError,
15
+ TransportQueryError,
16
+ TransportServerError,
17
+ )
18
+ from pipefy_auth import (
19
+ DiscoveryPolicy,
20
+ LoginError,
21
+ OidcClient,
22
+ RefreshError,
23
+ RevocationError,
24
+ RevocationUnsupportedError,
25
+ SessionDeleteError,
26
+ StoredSessionAuth,
27
+ delete_session,
28
+ ensure_fresh_session,
29
+ keychain_backend_name,
30
+ load_session,
31
+ revoke_session,
32
+ run_login,
33
+ store_session,
34
+ )
35
+ from pipefy_auth.settings import _LEGACY_ENV_KEYS_TO_NEW
36
+ from pipefy_sdk import MePayload, PipefySettings
37
+
38
+ from pipefy_cli._docs import DOCS_CLI_AUTH_REF
39
+ from pipefy_cli.auth import (
40
+ AuthContext,
41
+ DisplaySource,
42
+ detect_cli_auth_methods,
43
+ get_authenticated_client,
44
+ to_display_source,
45
+ )
46
+ from pipefy_cli.commands._common import settings_and_auth_from_ctx
47
+ from pipefy_cli.output import render_json
48
+
49
+ AuthSessionState = Literal["active", "refresh-expired", "needs-login", "n/a"]
50
+
51
+
52
+ @dataclass
53
+ class AuthStatusReport:
54
+ """Typed model for `auth status` rendering.
55
+
56
+ Distinct from the locked JSON wire schema (see :func:`_to_json_payload`):
57
+ renderers consume this typed value, not the wire dict, so the external
58
+ contract and the internal model can evolve independently.
59
+ """
60
+
61
+ auth_source: DisplaySource
62
+ detected_sources: list[DisplaySource]
63
+ issuer: str | None = None
64
+ state: AuthSessionState = "n/a"
65
+ access_expires_at: str | None = None
66
+ refresh_expires_at: str | None = None
67
+ identity: MePayload | None = None
68
+ token_rejected: bool = False
69
+ keychain_backend: str | None = None
70
+ masking_env_vars: list[str] = field(default_factory=list)
71
+
72
+ @property
73
+ def signed_in(self) -> bool:
74
+ return self.auth_source != "none"
75
+
76
+
77
+ auth_app = typer.Typer(
78
+ help="Authenticate the CLI as your Pipefy user (browser-based login).",
79
+ no_args_is_help=True,
80
+ )
81
+
82
+ _SERVICE_ACCOUNT_ENV_KEYS = tuple(_LEGACY_ENV_KEYS_TO_NEW.values())
83
+ _LEGACY_SERVICE_ACCOUNT_ENV_KEYS = tuple(_LEGACY_ENV_KEYS_TO_NEW.keys())
84
+
85
+
86
+ def _session_masking_env_vars() -> list[str]:
87
+ """Env vars in ``os.environ`` that outrank a stored session.
88
+
89
+ A service-account triple counts only when *complete* (otherwise the
90
+ client-credentials path wouldn't activate and the warning would mislead).
91
+ Both the canonical and legacy forms are accepted during the deprecation
92
+ window, and the label echoes whichever the user actually set so they know
93
+ which keys to unset.
94
+ """
95
+ env_vars: list[str] = []
96
+ if os.environ.get("PIPEFY_TOKEN"):
97
+ env_vars.append("PIPEFY_TOKEN")
98
+ if all(os.environ.get(k) for k in _SERVICE_ACCOUNT_ENV_KEYS):
99
+ env_vars.append("PIPEFY_SERVICE_ACCOUNT_*")
100
+ elif all(os.environ.get(k) for k in _LEGACY_SERVICE_ACCOUNT_ENV_KEYS):
101
+ env_vars.append("PIPEFY_OAUTH_*")
102
+ return env_vars
103
+
104
+
105
+ def _warn_if_masked() -> None:
106
+ env_vars = _session_masking_env_vars()
107
+ if not env_vars:
108
+ return
109
+ typer.echo(
110
+ f"Note: {', '.join(env_vars)} is set in your environment; other `pipefy` "
111
+ "commands will continue to use it until you unset it.",
112
+ err=True,
113
+ )
114
+
115
+
116
+ @auth_app.command("login")
117
+ def auth_login(
118
+ ctx: typer.Context,
119
+ no_browser: bool = typer.Option( # noqa: B008 (Typer Option pattern)
120
+ False,
121
+ "--no-browser",
122
+ help="Print the authorization URL instead of opening a browser.",
123
+ ),
124
+ callback_timeout: float = typer.Option( # noqa: B008
125
+ 180.0,
126
+ "--callback-timeout",
127
+ help="Seconds to wait for the browser callback before giving up.",
128
+ min=5.0,
129
+ ),
130
+ ) -> None:
131
+ """Sign in to Pipefy via your browser and store the session in the OS keychain."""
132
+ # Lazy to keep keyring's ~30-80ms backend-discovery cost off every CLI startup.
133
+ from keyring.errors import KeyringError
134
+
135
+ settings, auth = settings_and_auth_from_ctx(ctx)
136
+ if auth.oidc_client is None:
137
+ typer.echo(
138
+ "Stored sessions are disabled (PIPEFY_DISABLE_STORED_SESSION=1 or "
139
+ "disable_stored_session=true in config.toml). Unset to log in, or "
140
+ "use PIPEFY_TOKEN / PIPEFY_SERVICE_ACCOUNT_* for non-interactive auth.",
141
+ err=True,
142
+ )
143
+ raise typer.Exit(2)
144
+ issuer_url = auth.oidc_client.issuer_url
145
+ client_id = auth.oidc_client.client_id
146
+ typer.echo(f"Signing in to Pipefy at {issuer_url} ...")
147
+
148
+ def _print_url(url: str) -> None:
149
+ typer.echo(f"\nAuthorization URL: {url}\n")
150
+
151
+ def _open(url: str) -> bool:
152
+ if no_browser:
153
+ return False
154
+ if webbrowser.open(url):
155
+ return True
156
+ typer.echo(
157
+ "Could not open a browser automatically. Open the URL below, or "
158
+ "re-run with --no-browser.",
159
+ err=True,
160
+ )
161
+ return False
162
+
163
+ try:
164
+ result = run_login(
165
+ issuer_url=issuer_url,
166
+ client_id=client_id,
167
+ callback_timeout_s=callback_timeout,
168
+ open_browser=_open,
169
+ on_url=_print_url,
170
+ discovery_policy=DiscoveryPolicy(
171
+ allow_insecure_urls=settings.allow_insecure_urls
172
+ ),
173
+ )
174
+ except LoginError as exc:
175
+ typer.echo(f"Login failed: {exc}", err=True)
176
+ raise typer.Exit(1) from exc
177
+ except TimeoutError as exc:
178
+ typer.echo(f"Login timed out: {exc}", err=True)
179
+ raise typer.Exit(1) from exc
180
+ except KeyboardInterrupt:
181
+ typer.echo("Login cancelled.", err=True)
182
+ raise typer.Exit(130) from None
183
+
184
+ try:
185
+ store_session(
186
+ issuer=result.issuer,
187
+ client_id=client_id,
188
+ token=result.token,
189
+ )
190
+ except (KeyringError, OSError) as exc:
191
+ # ``OSError`` covers the file-backed PlaintextKeyring path: a read-only
192
+ # ``config_dir()`` (e.g. CI runner without write perms, NFS mount
193
+ # without ownership) surfaces as ``PermissionError`` from
194
+ # ``os.makedirs`` inside the backend, which is not a ``KeyringError``.
195
+ backend = keychain_backend_name()
196
+ if backend == "PlaintextKeyring":
197
+ from pipefy_infra.config import config_dir
198
+
199
+ hint = (
200
+ f"Ensure the config directory is writable ({config_dir()}), "
201
+ "or use a static PIPEFY_TOKEN."
202
+ )
203
+ else:
204
+ hint = (
205
+ "On headless Linux, ensure a Secret Service daemon "
206
+ "(gnome-keyring, kwallet) is running, set "
207
+ "PIPEFY_KEYCHAIN_BACKEND=file to use a plaintext file backend, "
208
+ "or use a static PIPEFY_TOKEN."
209
+ )
210
+ typer.echo(
211
+ f"Login succeeded but the session could not be stored in your "
212
+ f"keychain ({backend}): {exc}. {hint}",
213
+ err=True,
214
+ )
215
+ raise typer.Exit(1) from exc
216
+
217
+ typer.echo(
218
+ f"Signed in to Pipefy ({result.issuer}). Session stored in {keychain_backend_name()}."
219
+ )
220
+ _warn_if_masked()
221
+
222
+
223
+ _AUTH_SOURCE_LABELS: dict[DisplaySource, str] = {
224
+ "flag-token": "--token flag",
225
+ "env-token": "PIPEFY_TOKEN environment variable",
226
+ "service-account": "PIPEFY_SERVICE_ACCOUNT_* (client credentials)",
227
+ "stored-session": "stored session (`pipefy auth login`)",
228
+ "none": "none",
229
+ }
230
+
231
+
232
+ def _iso_expiry(obtained_at: int, lifetime_s: int | None) -> str | None:
233
+ """Compute the ISO 8601 expiry timestamp; ``None`` when ``lifetime_s`` is missing."""
234
+ if lifetime_s is None:
235
+ return None
236
+ return (
237
+ datetime.fromtimestamp(obtained_at + lifetime_s, tz=timezone.utc)
238
+ .isoformat()
239
+ .replace("+00:00", "Z")
240
+ )
241
+
242
+
243
+ def _format_relative(iso_ts: str | None) -> str:
244
+ """Render an ISO timestamp as a human-friendly "in 4h 12m" / "expired" string."""
245
+ if iso_ts is None:
246
+ return "unknown"
247
+ target = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
248
+ delta = target - datetime.now(tz=timezone.utc)
249
+ seconds = int(delta.total_seconds())
250
+ if seconds <= 0:
251
+ return "expired"
252
+ hours, remainder = divmod(seconds, 3600)
253
+ minutes = remainder // 60
254
+ if hours >= 24:
255
+ days, hours = divmod(hours, 24)
256
+ return f"in {days}d {hours}h"
257
+ return f"in {hours}h {minutes}m"
258
+
259
+
260
+ def _to_json_payload(report: AuthStatusReport) -> dict[str, Any]:
261
+ """Serialize the report to the locked JSON wire schema."""
262
+ return {
263
+ "signed_in": report.signed_in,
264
+ "identity": report.identity,
265
+ "auth_source": report.auth_source,
266
+ "detected_sources": report.detected_sources,
267
+ "issuer": report.issuer,
268
+ "state": report.state,
269
+ "access_expires_at": report.access_expires_at,
270
+ "refresh_expires_at": report.refresh_expires_at,
271
+ "token_rejected": report.token_rejected,
272
+ "keychain_backend": report.keychain_backend,
273
+ "masking_env_vars": report.masking_env_vars,
274
+ }
275
+
276
+
277
+ def _render_status_text(report: AuthStatusReport) -> None:
278
+ """Human-readable rendering of the status report (stdout)."""
279
+ if not report.signed_in:
280
+ typer.echo(
281
+ "Not signed in. Run `pipefy auth login`, set PIPEFY_TOKEN, or "
282
+ f"configure PIPEFY_SERVICE_ACCOUNT_*. See {DOCS_CLI_AUTH_REF}."
283
+ )
284
+ return
285
+
286
+ header = (
287
+ f"Signed in to Pipefy ({report.issuer})"
288
+ if report.issuer
289
+ else "Signed in to Pipefy"
290
+ )
291
+ typer.echo(header)
292
+
293
+ if report.identity is not None:
294
+ email = report.identity["email"]
295
+ name = report.identity.get("name")
296
+ identity_label = f"{email} ({name})" if name else email
297
+ typer.echo(f" Identity: {identity_label}")
298
+ elif report.token_rejected:
299
+ typer.echo(" Identity: unknown (token rejected by upstream)")
300
+ else:
301
+ typer.echo(" Identity: unknown")
302
+
303
+ typer.echo(f" Auth source: {_AUTH_SOURCE_LABELS[report.auth_source]}")
304
+
305
+ if report.auth_source == "stored-session":
306
+ typer.echo(f" Expires: {_format_relative(report.access_expires_at)}")
307
+ typer.echo(f" Refresh token: {_format_relative(report.refresh_expires_at)}")
308
+ if report.keychain_backend:
309
+ typer.echo(f" Keychain: {report.keychain_backend}")
310
+
311
+ if report.masking_env_vars:
312
+ typer.echo("")
313
+ typer.echo("Other auth sources detected in env:")
314
+ for name in report.masking_env_vars:
315
+ typer.echo(
316
+ f" {name} (would mask the stored session if no --token is passed)"
317
+ )
318
+
319
+
320
+ def _render(report: AuthStatusReport, *, json_out: bool) -> None:
321
+ """Render the report on stdout in the requested format."""
322
+ if json_out:
323
+ render_json(_to_json_payload(report))
324
+ else:
325
+ _render_status_text(report)
326
+
327
+
328
+ @dataclass
329
+ class _StatusExit(Exception):
330
+ """Control-flow signal raised by phase helpers; translated to ``typer.Exit`` at the command boundary.
331
+
332
+ Carries the report (which may differ from the in-progress one — see the vanished-session
333
+ branch) along with the exit code and optional stderr message.
334
+ """
335
+
336
+ report: AuthStatusReport
337
+ exit_code: int
338
+ stderr: str | None = None
339
+
340
+
341
+ def _populate_stored_session(report: AuthStatusReport, oidc: OidcClient) -> None:
342
+ """Populate stored-session fields on ``report``; raise ``_StatusExit`` on failure."""
343
+ report.issuer = oidc.issuer_url
344
+ report.keychain_backend = keychain_backend_name()
345
+ try:
346
+ fresh_session = ensure_fresh_session(
347
+ issuer=oidc.issuer_url, client_id=oidc.client_id
348
+ )
349
+ except RefreshError as exc:
350
+ # Branch on the RFC 6749 ``error`` field, not on ``str(exc)``. The
351
+ # message text is for humans; ``error_code`` is the machine contract.
352
+ report.state = (
353
+ "refresh-expired" if exc.error_code == "invalid_grant" else "needs-login"
354
+ )
355
+ # Best-effort expiry from the pre-refresh blob so users see *why*.
356
+ stale = load_session(issuer=oidc.issuer_url, client_id=oidc.client_id)
357
+ if stale is not None:
358
+ report.access_expires_at = _iso_expiry(
359
+ stale.obtained_at, stale.token.expires_in
360
+ )
361
+ report.refresh_expires_at = _iso_expiry(
362
+ stale.obtained_at, stale.token.refresh_expires_in
363
+ )
364
+ raise _StatusExit(
365
+ report=report,
366
+ exit_code=2,
367
+ stderr=(
368
+ f"Stored Pipefy session could not be refreshed: {exc}. "
369
+ "Run `pipefy auth login` to sign in again."
370
+ ),
371
+ ) from exc
372
+ if fresh_session is None:
373
+ # Session vanished between detection and refresh.
374
+ raise _StatusExit(
375
+ report=AuthStatusReport(
376
+ auth_source="none", detected_sources=report.detected_sources
377
+ ),
378
+ exit_code=2,
379
+ )
380
+ report.state = "active"
381
+ report.access_expires_at = _iso_expiry(
382
+ fresh_session.obtained_at, fresh_session.token.expires_in
383
+ )
384
+ report.refresh_expires_at = _iso_expiry(
385
+ fresh_session.obtained_at, fresh_session.token.refresh_expires_in
386
+ )
387
+
388
+
389
+ def _fetch_identity(
390
+ report: AuthStatusReport, settings: PipefySettings, auth: AuthContext
391
+ ) -> None:
392
+ """Populate ``report.identity``; raise ``_StatusExit`` on transport / 401."""
393
+ client = get_authenticated_client(settings, auth)
394
+ try:
395
+ report.identity = asyncio.run(client.get_me())
396
+ except TransportServerError as exc:
397
+ # Pipefy returns a literal HTTP 401 for invalid bearers (verified via
398
+ # `curl -X POST <graphql>` with a bad token); other HTTP errors here
399
+ # are upstream/transport problems, not credential rejections.
400
+ if exc.code == 401:
401
+ report.token_rejected = True
402
+ raise _StatusExit(
403
+ report=report, exit_code=1, stderr=f"Identity fetch failed: {exc}"
404
+ ) from exc
405
+ except (TransportQueryError, TransportError) as exc:
406
+ raise _StatusExit(
407
+ report=report, exit_code=1, stderr=f"Pipefy transport error: {exc}"
408
+ ) from exc
409
+
410
+
411
+ @auth_app.command("status")
412
+ def auth_status(
413
+ ctx: typer.Context,
414
+ json_out: bool = typer.Option( # noqa: B008 (Typer Option pattern)
415
+ False,
416
+ "--json",
417
+ "-j",
418
+ help="Emit a stable JSON schema instead of human-readable text.",
419
+ ),
420
+ ) -> None:
421
+ """Print which auth source is active, the authenticated identity, and session expiry."""
422
+ settings, auth = settings_and_auth_from_ctx(ctx)
423
+ auth_methods = detect_cli_auth_methods(auth)
424
+ detected_sources: list[DisplaySource] = [
425
+ to_display_source(method, auth.bearer_token) for method in auth_methods
426
+ ]
427
+ # Precedence-first, so the head is the active method and the same winner the
428
+ # resolver would pick, without a second resolve pass.
429
+ active = auth_methods[0] if auth_methods else None
430
+ source: DisplaySource = (
431
+ to_display_source(active, auth.bearer_token) if active else "none"
432
+ )
433
+ report = AuthStatusReport(auth_source=source, detected_sources=detected_sources)
434
+ # Surface masking env vars whenever a stored session exists: that's the
435
+ # CI-overrides-keychain failure mode the field is for, and the higher-
436
+ # precedence winner is the case where it matters.
437
+ if any(isinstance(method, StoredSessionAuth) for method in auth_methods):
438
+ report.masking_env_vars = _session_masking_env_vars()
439
+
440
+ try:
441
+ if active is None:
442
+ raise _StatusExit(report=report, exit_code=2)
443
+ # A StoredSessionAuth carries its OIDC client by construction, so
444
+ # populating session details needs no presence check.
445
+ if isinstance(active, StoredSessionAuth):
446
+ _populate_stored_session(report, active.oidc_client)
447
+ _fetch_identity(report, settings, auth)
448
+ except _StatusExit as exit_:
449
+ _render(exit_.report, json_out=json_out)
450
+ if exit_.stderr and not json_out:
451
+ typer.echo(exit_.stderr, err=True)
452
+ raise typer.Exit(exit_.exit_code) from exit_
453
+
454
+ _render(report, json_out=json_out)
455
+
456
+
457
+ @auth_app.command("logout")
458
+ def auth_logout(ctx: typer.Context) -> None:
459
+ """Revoke the stored refresh token at the IdP and clear the local session."""
460
+ settings, auth = settings_and_auth_from_ctx(ctx)
461
+ if auth.oidc_client is None:
462
+ typer.echo(
463
+ "Stored sessions are disabled (PIPEFY_DISABLE_STORED_SESSION=1 or "
464
+ "disable_stored_session=true in config.toml). Nothing to do.",
465
+ err=True,
466
+ )
467
+ raise typer.Exit(2)
468
+ issuer = auth.oidc_client.issuer_url
469
+ client_id = auth.oidc_client.client_id
470
+
471
+ session = load_session(issuer=issuer, client_id=client_id)
472
+ if session is None:
473
+ typer.echo("Not signed in. Nothing to do.")
474
+ return
475
+
476
+ # Revoke at the IdP first — once we delete the keychain entry we no longer
477
+ # have the refresh_token to send. On any revoke failure still proceed to
478
+ # delete (warning makes the difference honest: server-side credential may
479
+ # remain valid until natural expiry).
480
+ revoke_warning: str | None = None
481
+ try:
482
+ revoke_session(
483
+ issuer=issuer,
484
+ client_id=client_id,
485
+ refresh_token=session.token.refresh_token,
486
+ policy=DiscoveryPolicy(allow_insecure_urls=settings.allow_insecure_urls),
487
+ )
488
+ except RevocationUnsupportedError:
489
+ revoke_warning = (
490
+ "Pipefy auth server does not advertise a logout endpoint; the "
491
+ "refresh token could not be revoked server-side. Clearing local "
492
+ "session only."
493
+ )
494
+ except RevocationError as exc:
495
+ revoke_warning = (
496
+ f"Could not revoke refresh token at the IdP: {exc}. Clearing "
497
+ "local session anyway; the refresh token may remain valid at the "
498
+ "server until natural expiry."
499
+ )
500
+
501
+ try:
502
+ delete_session(issuer=issuer, client_id=client_id)
503
+ except SessionDeleteError as exc:
504
+ if revoke_warning:
505
+ typer.echo(revoke_warning, err=True)
506
+ typer.echo(
507
+ f"Could not delete local session from the keychain: {exc}. "
508
+ "The stored credential may still be present; remove it manually "
509
+ f"via your OS keychain (service: 'pipefy'). See {DOCS_CLI_AUTH_REF}.",
510
+ err=True,
511
+ )
512
+ raise typer.Exit(1) from exc
513
+
514
+ if revoke_warning:
515
+ typer.echo(revoke_warning, err=True)
516
+ typer.echo(f"Signed out of Pipefy ({issuer}).")
517
+
518
+
519
+ __all__ = ["auth_app"]