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.
- pipefy_cli/__init__.py +7 -0
- pipefy_cli/_docs.py +3 -0
- pipefy_cli/auth.py +225 -0
- pipefy_cli/commands/__init__.py +5 -0
- pipefy_cli/commands/_common.py +359 -0
- pipefy_cli/commands/agent.py +358 -0
- pipefy_cli/commands/ai_automation.py +318 -0
- pipefy_cli/commands/attachment.py +122 -0
- pipefy_cli/commands/audit.py +63 -0
- pipefy_cli/commands/auth.py +519 -0
- pipefy_cli/commands/automation.py +529 -0
- pipefy_cli/commands/card.py +439 -0
- pipefy_cli/commands/email.py +169 -0
- pipefy_cli/commands/export.py +61 -0
- pipefy_cli/commands/field.py +113 -0
- pipefy_cli/commands/field_condition.py +201 -0
- pipefy_cli/commands/graphql.py +110 -0
- pipefy_cli/commands/introspect.py +110 -0
- pipefy_cli/commands/label.py +109 -0
- pipefy_cli/commands/member.py +126 -0
- pipefy_cli/commands/org.py +60 -0
- pipefy_cli/commands/phase.py +264 -0
- pipefy_cli/commands/pipe.py +167 -0
- pipefy_cli/commands/portal.py +871 -0
- pipefy_cli/commands/record.py +219 -0
- pipefy_cli/commands/relation.py +173 -0
- pipefy_cli/commands/report_org.py +241 -0
- pipefy_cli/commands/report_pipe.py +290 -0
- pipefy_cli/commands/table.py +151 -0
- pipefy_cli/commands/usage.py +100 -0
- pipefy_cli/commands/webhook.py +141 -0
- pipefy_cli/main.py +132 -0
- pipefy_cli/output/__init__.py +8 -0
- pipefy_cli/output/json_renderer.py +24 -0
- pipefy_cli/output/rich_renderer.py +81 -0
- pipefy_cli/settings.py +66 -0
- pipefy_cli-0.3.0a1.dist-info/METADATA +104 -0
- pipefy_cli-0.3.0a1.dist-info/RECORD +40 -0
- pipefy_cli-0.3.0a1.dist-info/WHEEL +4 -0
- 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"]
|