publinker-cli 0.6.2__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.
- publinker_cli/__init__.py +14 -0
- publinker_cli/__main__.py +83 -0
- publinker_cli/audience.py +73 -0
- publinker_cli/auth.py +152 -0
- publinker_cli/client.py +166 -0
- publinker_cli/commands/__init__.py +0 -0
- publinker_cli/commands/auth_cmds.py +69 -0
- publinker_cli/commands/report_options.py +100 -0
- publinker_cli/commands/skill.py +46 -0
- publinker_cli/commands/token_cmd.py +97 -0
- publinker_cli/commands/update.py +207 -0
- publinker_cli/commands/use.py +100 -0
- publinker_cli/commands/write.py +72 -0
- publinker_cli/config.py +64 -0
- publinker_cli/dispatch_cli.py +394 -0
- publinker_cli/keyring_store.py +109 -0
- publinker_cli/manifest.py +409 -0
- publinker_cli/routes.yaml +1648 -0
- publinker_cli/skill/SKILL.md +61 -0
- publinker_cli/state.py +138 -0
- publinker_cli/version.py +58 -0
- publinker_cli/write_gate.py +51 -0
- publinker_cli-0.6.2.dist-info/METADATA +81 -0
- publinker_cli-0.6.2.dist-info/RECORD +26 -0
- publinker_cli-0.6.2.dist-info/WHEEL +4 -0
- publinker_cli-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""publinker-cli — per-user OAuth CLI over publinker-api.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
- `PublinkerClient` — authenticated HTTP client (importable for the future MCP wrapper).
|
|
5
|
+
- `app` (in `publinker_cli.__main__`) — Typer CLI entry point.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Defined before the client import: client.py reads it for the User-Agent.
|
|
9
|
+
# Bump together with pyproject.toml and the server-side latest-version constant.
|
|
10
|
+
__version__ = "0.6.2"
|
|
11
|
+
|
|
12
|
+
from publinker_cli.client import PublinkerClient # noqa: E402
|
|
13
|
+
|
|
14
|
+
__all__ = ["PublinkerClient", "__version__"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""publinker — CLI entry point.
|
|
2
|
+
|
|
3
|
+
`pip install publinker-cli` registers this module as the `publinker` console script
|
|
4
|
+
via the [project.scripts] section of pyproject.toml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from publinker_cli import __version__
|
|
12
|
+
from publinker_cli.commands import auth_cmds, use as use_cmd
|
|
13
|
+
from publinker_cli.commands import report_options, token_cmd, update as update_cmd
|
|
14
|
+
from publinker_cli.commands import skill as skill_cmd, write as write_cmd
|
|
15
|
+
from publinker_cli.dispatch_cli import register_routes
|
|
16
|
+
from publinker_cli.manifest import load_manifest
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="publinker",
|
|
20
|
+
help="CLI for publinker-api. Per-user Auth0 OAuth, OS-keychain token storage.",
|
|
21
|
+
add_completion=True,
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Hand-written top-level commands (auth lifecycle + site selector).
|
|
26
|
+
app.command("login")(auth_cmds.login)
|
|
27
|
+
app.command("logout")(auth_cmds.logout)
|
|
28
|
+
app.command("whoami")(auth_cmds.whoami)
|
|
29
|
+
app.command(
|
|
30
|
+
"token",
|
|
31
|
+
help=(
|
|
32
|
+
"Print a live access token (stdout only) for curl/browser debugging. "
|
|
33
|
+
"--id for the id_token, --json for the full bundle."
|
|
34
|
+
),
|
|
35
|
+
)(token_cmd.token_command)
|
|
36
|
+
app.command(
|
|
37
|
+
"use",
|
|
38
|
+
help="Set or clear the active publinker site (persisted across sessions).",
|
|
39
|
+
)(use_cmd.use_command)
|
|
40
|
+
app.command(
|
|
41
|
+
"update",
|
|
42
|
+
help="Check for (--check) or install a newer publinker-cli. No login needed.",
|
|
43
|
+
)(update_cmd.update_command)
|
|
44
|
+
app.add_typer(write_cmd.app, name="write")
|
|
45
|
+
app.add_typer(skill_cmd.app, name="skill")
|
|
46
|
+
|
|
47
|
+
# Everything else (manifest-defined reads and guarded mutations) is materialized
|
|
48
|
+
# from publinker_cli/routes.yaml. Adding an API operation is a YAML edit plus a
|
|
49
|
+
# passing test run; no command boilerplate is required.
|
|
50
|
+
_groups = register_routes(app, load_manifest())
|
|
51
|
+
|
|
52
|
+
# Hand-written companion to `site report get` — surfaces the platform +
|
|
53
|
+
# dimensions_keys enums we've verified against prod.
|
|
54
|
+
_report_group = _groups.get(("site", "report"))
|
|
55
|
+
if _report_group is not None:
|
|
56
|
+
_report_group.command(
|
|
57
|
+
"list-options",
|
|
58
|
+
help="Show verified platform + dimensions_keys values for `report get`.",
|
|
59
|
+
)(report_options.list_options)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _version_callback(value: bool) -> None:
|
|
63
|
+
if value:
|
|
64
|
+
typer.echo(f"publinker {__version__}")
|
|
65
|
+
raise typer.Exit()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.callback()
|
|
69
|
+
def _root(
|
|
70
|
+
version: bool = typer.Option(
|
|
71
|
+
None,
|
|
72
|
+
"--version",
|
|
73
|
+
"-V",
|
|
74
|
+
callback=_version_callback,
|
|
75
|
+
is_eager=True,
|
|
76
|
+
help="Show version and exit.",
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""publinker — CLI for publinker-api."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
app()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Which slice of the route manifest this build exposes (DEV-1566).
|
|
2
|
+
|
|
3
|
+
The manifest describes 130 routes, including the whole `/overwatch` admin
|
|
4
|
+
surface, billing, invoices, subscriptions and Auth0 user management. Shipping
|
|
5
|
+
all of it to an external customer is an information-disclosure problem — the
|
|
6
|
+
wheel becomes a map of the internal API — and the audit that produced
|
|
7
|
+
DEV-1566 flagged it as a blocker for external release.
|
|
8
|
+
|
|
9
|
+
Every route therefore carries `audience: customer | internal`. **`internal` is
|
|
10
|
+
the default and the safe one**: a route added without an explicit audience
|
|
11
|
+
never reaches a customer build, so forgetting the field fails closed.
|
|
12
|
+
|
|
13
|
+
Resolution order, first match wins:
|
|
14
|
+
|
|
15
|
+
1. ``PUBLINKER_CLI_AUDIENCE`` in the environment — for tests and for an
|
|
16
|
+
internal user who wants to see the customer surface without reinstalling.
|
|
17
|
+
2. ``_BUILD_AUDIENCE`` below, rewritten by ``scripts/build-customer-wheel.sh``
|
|
18
|
+
when packaging the customer wheel.
|
|
19
|
+
3. ``internal`` — a plain ``pip install -e .`` from the repo is an internal
|
|
20
|
+
developer install.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
INTERNAL = "internal"
|
|
28
|
+
CUSTOMER = "customer"
|
|
29
|
+
VALID = (CUSTOMER, INTERNAL)
|
|
30
|
+
|
|
31
|
+
ENV_VAR = "PUBLINKER_CLI_AUDIENCE"
|
|
32
|
+
|
|
33
|
+
# Rewritten at package time by scripts/build-customer-wheel.sh. Leave as
|
|
34
|
+
# "internal" in the repo — the committed state is the developer install.
|
|
35
|
+
_BUILD_AUDIENCE = "customer"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AudienceError(ValueError):
|
|
39
|
+
"""Raised when an audience value is not one we recognise."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate(value: str, source: str) -> str:
|
|
43
|
+
if value not in VALID:
|
|
44
|
+
raise AudienceError(
|
|
45
|
+
f"{source} is {value!r}; expected one of {', '.join(VALID)}"
|
|
46
|
+
)
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def current_audience() -> str:
|
|
51
|
+
"""The audience this process should expose."""
|
|
52
|
+
override = os.environ.get(ENV_VAR, "").strip().lower()
|
|
53
|
+
if override:
|
|
54
|
+
return _validate(override, f"${ENV_VAR}")
|
|
55
|
+
return _validate(_BUILD_AUDIENCE, "the packaged build audience")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_customer_build() -> bool:
|
|
59
|
+
return current_audience() == CUSTOMER
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def route_is_visible(route_audience: str | None, audience: str | None = None) -> bool:
|
|
63
|
+
"""Whether a route with this `audience` ships in the given build.
|
|
64
|
+
|
|
65
|
+
An internal build sees everything. A customer build sees only routes
|
|
66
|
+
explicitly marked `customer` — anything missing, misspelled or `internal`
|
|
67
|
+
is hidden, so the failure mode is a missing command rather than an
|
|
68
|
+
exposed admin endpoint.
|
|
69
|
+
"""
|
|
70
|
+
audience = audience or current_audience()
|
|
71
|
+
if audience == INTERNAL:
|
|
72
|
+
return True
|
|
73
|
+
return route_audience == CUSTOMER
|
publinker_cli/auth.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Auth0 Device Authorization Flow (RFC 8628).
|
|
2
|
+
|
|
3
|
+
Two operations:
|
|
4
|
+
- `device_login()` — runs the full device flow end-to-end; returns a TokenBundle.
|
|
5
|
+
- `refresh_token(...)` — exchanges a refresh token for a new access token.
|
|
6
|
+
|
|
7
|
+
Both are pure: no keyring writes. The caller decides what to persist.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
import webbrowser
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from publinker_cli.config import Auth0Config, auth0_config
|
|
20
|
+
from publinker_cli.keyring_store import TokenBundle
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthError(Exception):
|
|
24
|
+
"""Raised when the Auth0 flow fails terminally (user denied, expired, server error)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DeviceCode:
|
|
29
|
+
device_code: str
|
|
30
|
+
user_code: str
|
|
31
|
+
verification_uri_complete: str
|
|
32
|
+
expires_in: int # seconds
|
|
33
|
+
interval: int # poll seconds
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def request_device_code(cfg: Optional[Auth0Config] = None) -> DeviceCode:
|
|
37
|
+
"""Step 1 of RFC 8628 — request a device code from Auth0."""
|
|
38
|
+
cfg = cfg or auth0_config()
|
|
39
|
+
resp = requests.post(
|
|
40
|
+
cfg.device_code_url,
|
|
41
|
+
data={
|
|
42
|
+
"client_id": cfg.client_id,
|
|
43
|
+
"scope": cfg.scopes,
|
|
44
|
+
"audience": cfg.audience,
|
|
45
|
+
},
|
|
46
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
47
|
+
timeout=10,
|
|
48
|
+
)
|
|
49
|
+
if resp.status_code != 200:
|
|
50
|
+
raise AuthError(f"device code request failed: {resp.status_code} {resp.text}")
|
|
51
|
+
body = resp.json()
|
|
52
|
+
return DeviceCode(
|
|
53
|
+
device_code=body["device_code"],
|
|
54
|
+
user_code=body["user_code"],
|
|
55
|
+
verification_uri_complete=body["verification_uri_complete"],
|
|
56
|
+
expires_in=int(body["expires_in"]),
|
|
57
|
+
interval=int(body["interval"]),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def poll_for_token(
|
|
62
|
+
device_code: DeviceCode,
|
|
63
|
+
cfg: Optional[Auth0Config] = None,
|
|
64
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
65
|
+
now: Callable[[], float] = time.monotonic,
|
|
66
|
+
) -> TokenBundle:
|
|
67
|
+
"""Step 2 of RFC 8628 — poll the token endpoint until the user authorizes or it expires.
|
|
68
|
+
|
|
69
|
+
`sleep` and `now` are injectable for tests.
|
|
70
|
+
"""
|
|
71
|
+
cfg = cfg or auth0_config()
|
|
72
|
+
interval = device_code.interval
|
|
73
|
+
deadline = now() + device_code.expires_in
|
|
74
|
+
|
|
75
|
+
while now() < deadline:
|
|
76
|
+
sleep(interval)
|
|
77
|
+
resp = requests.post(
|
|
78
|
+
cfg.token_url,
|
|
79
|
+
data={
|
|
80
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
81
|
+
"device_code": device_code.device_code,
|
|
82
|
+
"client_id": cfg.client_id,
|
|
83
|
+
},
|
|
84
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
85
|
+
timeout=10,
|
|
86
|
+
)
|
|
87
|
+
if resp.status_code == 200:
|
|
88
|
+
return TokenBundle.from_token_response(resp.json())
|
|
89
|
+
|
|
90
|
+
# 4xx with `error` field — either keep polling (pending/slow_down) or bail.
|
|
91
|
+
try:
|
|
92
|
+
err = resp.json().get("error", "")
|
|
93
|
+
except ValueError:
|
|
94
|
+
err = ""
|
|
95
|
+
|
|
96
|
+
if err == "authorization_pending":
|
|
97
|
+
continue
|
|
98
|
+
if err == "slow_down":
|
|
99
|
+
interval += 5
|
|
100
|
+
continue
|
|
101
|
+
if err in {"access_denied", "expired_token"}:
|
|
102
|
+
raise AuthError(f"login failed: {err}")
|
|
103
|
+
raise AuthError(
|
|
104
|
+
f"unexpected token endpoint response {resp.status_code}: {resp.text}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
raise AuthError("device code expired before user completed login")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def refresh_access_token(
|
|
111
|
+
refresh_token: str, cfg: Optional[Auth0Config] = None
|
|
112
|
+
) -> TokenBundle:
|
|
113
|
+
"""Exchange a refresh token for a new access token (and rotated refresh token)."""
|
|
114
|
+
cfg = cfg or auth0_config()
|
|
115
|
+
resp = requests.post(
|
|
116
|
+
cfg.token_url,
|
|
117
|
+
data={
|
|
118
|
+
"grant_type": "refresh_token",
|
|
119
|
+
"client_id": cfg.client_id,
|
|
120
|
+
"refresh_token": refresh_token,
|
|
121
|
+
},
|
|
122
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
123
|
+
timeout=10,
|
|
124
|
+
)
|
|
125
|
+
if resp.status_code != 200:
|
|
126
|
+
raise AuthError(f"refresh failed: {resp.status_code} {resp.text}")
|
|
127
|
+
body = resp.json()
|
|
128
|
+
# Auth0 rotates refresh tokens — the response may contain a NEW refresh_token, or
|
|
129
|
+
# (rarely) reuse the old one. Carry forward the prior refresh token if the response
|
|
130
|
+
# omits it, so the caller never loses ability to refresh again.
|
|
131
|
+
if "refresh_token" not in body:
|
|
132
|
+
body["refresh_token"] = refresh_token
|
|
133
|
+
return TokenBundle.from_token_response(body)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def device_login(
|
|
137
|
+
cfg: Optional[Auth0Config] = None,
|
|
138
|
+
open_browser: bool = True,
|
|
139
|
+
on_user_code: Optional[Callable[[DeviceCode], None]] = None,
|
|
140
|
+
) -> TokenBundle:
|
|
141
|
+
"""Run the full device flow end-to-end. Returns the new token bundle.
|
|
142
|
+
|
|
143
|
+
`on_user_code` is invoked with the device code so the caller can print the
|
|
144
|
+
user_code + verification URL. If `open_browser` is True the verification URL
|
|
145
|
+
is also opened in the default browser.
|
|
146
|
+
"""
|
|
147
|
+
code = request_device_code(cfg)
|
|
148
|
+
if on_user_code is not None:
|
|
149
|
+
on_user_code(code)
|
|
150
|
+
if open_browser:
|
|
151
|
+
webbrowser.open(code.verification_uri_complete)
|
|
152
|
+
return poll_for_token(code, cfg)
|
publinker_cli/client.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Authenticated HTTP client for publinker-api.
|
|
2
|
+
|
|
3
|
+
Importable by both the CLI commands and the future MCP server. Handles silent
|
|
4
|
+
token refresh on 401 — the caller never sees an auth retry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from publinker_cli import auth, keyring_store, version
|
|
17
|
+
from publinker_cli.config import DEFAULT_STAGE, api_base_url
|
|
18
|
+
|
|
19
|
+
# A ready access token (e.g. from `publinker token` on a logged-in machine) for
|
|
20
|
+
# CI and containers. Used as-is: never stored, never refreshed.
|
|
21
|
+
TOKEN_ENV_VAR = "PUBLINKER_TOKEN"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotAuthenticatedError(Exception):
|
|
25
|
+
"""No token in keyring (or it's been revoked and can't be refreshed)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class APIError(Exception):
|
|
29
|
+
"""publinker-api returned a non-2xx response."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status: int, body: str, path: str) -> None:
|
|
32
|
+
super().__init__(f"{path} → HTTP {status}: {body}")
|
|
33
|
+
self.status = status
|
|
34
|
+
self.body = body
|
|
35
|
+
self.path = path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PublinkerClient:
|
|
39
|
+
"""Thin authenticated wrapper over publinker-api.
|
|
40
|
+
|
|
41
|
+
Auto-refreshes the access token when it's about to expire (60s leeway) and on
|
|
42
|
+
a 401 response (one retry). After a failed refresh the bundle is cleared from
|
|
43
|
+
the keyring so the next operation triggers a fresh `login`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, stage: str = DEFAULT_STAGE, session: Optional[requests.Session] = None
|
|
48
|
+
) -> None:
|
|
49
|
+
self.stage = stage
|
|
50
|
+
self.base_url = api_base_url(stage).rstrip("/")
|
|
51
|
+
self._session = session or requests.Session()
|
|
52
|
+
|
|
53
|
+
# ─── Auth token management ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def _bundle(self) -> keyring_store.TokenBundle:
|
|
56
|
+
env_token = os.environ.get(TOKEN_ENV_VAR, "").strip()
|
|
57
|
+
if env_token:
|
|
58
|
+
return keyring_store.TokenBundle(env_token, "", "", 2**31 - 1)
|
|
59
|
+
bundle = keyring_store.load()
|
|
60
|
+
if bundle is None:
|
|
61
|
+
raise NotAuthenticatedError("not logged in — run `publinker login`")
|
|
62
|
+
if bundle.is_expired():
|
|
63
|
+
bundle = self._refresh(bundle)
|
|
64
|
+
return bundle
|
|
65
|
+
|
|
66
|
+
def current_bundle(self) -> keyring_store.TokenBundle:
|
|
67
|
+
"""Return a token bundle guaranteed valid right now.
|
|
68
|
+
|
|
69
|
+
Public counterpart to `_bundle()` for callers that need the token itself
|
|
70
|
+
rather than an API call (e.g. `publinker token`). Refreshes and re-saves
|
|
71
|
+
when expired; raises NotAuthenticatedError when the session is gone.
|
|
72
|
+
"""
|
|
73
|
+
return self._bundle()
|
|
74
|
+
|
|
75
|
+
def _refresh(self, bundle: keyring_store.TokenBundle) -> keyring_store.TokenBundle:
|
|
76
|
+
if os.environ.get(TOKEN_ENV_VAR, "").strip():
|
|
77
|
+
# Leave the stored login alone: the env token is the one that failed.
|
|
78
|
+
raise NotAuthenticatedError(
|
|
79
|
+
f"${TOKEN_ENV_VAR} was rejected (expired?) — "
|
|
80
|
+
"mint a new one with `publinker token`"
|
|
81
|
+
)
|
|
82
|
+
if not bundle.refresh_token:
|
|
83
|
+
keyring_store.clear()
|
|
84
|
+
raise NotAuthenticatedError("session expired — run `publinker login`")
|
|
85
|
+
try:
|
|
86
|
+
new_bundle = auth.refresh_access_token(bundle.refresh_token)
|
|
87
|
+
except auth.AuthError as exc:
|
|
88
|
+
keyring_store.clear()
|
|
89
|
+
raise NotAuthenticatedError(
|
|
90
|
+
f"session expired ({exc}) — run `publinker login`"
|
|
91
|
+
) from exc
|
|
92
|
+
keyring_store.save(new_bundle)
|
|
93
|
+
return new_bundle
|
|
94
|
+
|
|
95
|
+
# ─── HTTP ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict:
|
|
98
|
+
bundle = self._bundle()
|
|
99
|
+
url = f"{self.base_url}{path}"
|
|
100
|
+
headers = kwargs.pop("headers", {})
|
|
101
|
+
headers["Authorization"] = f"Bearer {bundle.access_token}"
|
|
102
|
+
headers["User-Agent"] = version.USER_AGENT
|
|
103
|
+
|
|
104
|
+
resp = self._session.request(method, url, headers=headers, timeout=30, **kwargs)
|
|
105
|
+
if resp.status_code == 401:
|
|
106
|
+
# The token wasn't actually expired by our clock, but the API rejected it.
|
|
107
|
+
# Force a refresh and retry once.
|
|
108
|
+
bundle = self._refresh(bundle)
|
|
109
|
+
headers["Authorization"] = f"Bearer {bundle.access_token}"
|
|
110
|
+
resp = self._session.request(
|
|
111
|
+
method, url, headers=headers, timeout=30, **kwargs
|
|
112
|
+
)
|
|
113
|
+
if not resp.ok:
|
|
114
|
+
raise APIError(resp.status_code, resp.text, path)
|
|
115
|
+
version.observe(resp.headers)
|
|
116
|
+
|
|
117
|
+
# The API returns 204 No Content for some endpoints; tolerate empty bodies.
|
|
118
|
+
if not resp.content:
|
|
119
|
+
return {}
|
|
120
|
+
try:
|
|
121
|
+
return resp.json()
|
|
122
|
+
except ValueError:
|
|
123
|
+
return {"raw": resp.text}
|
|
124
|
+
|
|
125
|
+
def get(self, path: str, **kwargs: Any) -> dict:
|
|
126
|
+
return self._request("GET", path, **kwargs)
|
|
127
|
+
|
|
128
|
+
def request(self, method: str, path: str, **kwargs: Any) -> dict:
|
|
129
|
+
"""Public method/path entry point used by the manifest dispatcher."""
|
|
130
|
+
return self._request(method, path, **kwargs)
|
|
131
|
+
|
|
132
|
+
# ─── v0 resource shortcuts ──────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def list_sites(self) -> list[str]:
|
|
135
|
+
"""`GET /site` → list of site domain strings."""
|
|
136
|
+
return self.get("/site").get("sites", [])
|
|
137
|
+
|
|
138
|
+
def list_companies(self) -> list[str]:
|
|
139
|
+
"""`GET /company` → list of company id strings."""
|
|
140
|
+
return self.get("/company").get("companyIds", [])
|
|
141
|
+
|
|
142
|
+
# ─── Local profile ──────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def whoami(self) -> dict:
|
|
145
|
+
"""Decode the id_token locally — no API call, no token cost.
|
|
146
|
+
|
|
147
|
+
Returns the JWT claims (email, name, sub, etc.). The id_token's signature
|
|
148
|
+
is NOT verified here — we issued it ourselves to ourselves; tampering would
|
|
149
|
+
only fool the user, not the API.
|
|
150
|
+
"""
|
|
151
|
+
bundle = keyring_store.load()
|
|
152
|
+
if bundle is None or not bundle.id_token:
|
|
153
|
+
raise NotAuthenticatedError("not logged in — run `publinker login`")
|
|
154
|
+
return _decode_jwt_payload(bundle.id_token)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _decode_jwt_payload(token: str) -> dict:
|
|
158
|
+
"""Decode the payload segment of a JWT without verifying the signature."""
|
|
159
|
+
try:
|
|
160
|
+
_, payload_b64, _ = token.split(".")
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
raise ValueError(f"malformed JWT: {exc}") from exc
|
|
163
|
+
# JWT uses base64url without padding — re-pad before decoding.
|
|
164
|
+
padding = "=" * (-len(payload_b64) % 4)
|
|
165
|
+
decoded = base64.urlsafe_b64decode(payload_b64 + padding)
|
|
166
|
+
return json.loads(decoded)
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""login / logout / whoami commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from publinker_cli import auth, keyring_store, state, write_gate
|
|
10
|
+
from publinker_cli.client import NotAuthenticatedError, PublinkerClient
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def login() -> None:
|
|
16
|
+
"""Authenticate via Auth0 Device Authorization Flow."""
|
|
17
|
+
|
|
18
|
+
def on_user_code(code: auth.DeviceCode) -> None:
|
|
19
|
+
console.print(
|
|
20
|
+
"\n[bold]Confirm the code in your browser:[/bold] "
|
|
21
|
+
f"[cyan bold]{code.user_code}[/cyan bold]"
|
|
22
|
+
)
|
|
23
|
+
console.print(f"[dim]URL: {code.verification_uri_complete}[/dim]")
|
|
24
|
+
console.print(
|
|
25
|
+
f"[dim]Opening your browser... (expires in {code.expires_in // 60} min)[/dim]\n"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
with console.status("[bold green]Waiting for authorization...", spinner="dots"):
|
|
29
|
+
try:
|
|
30
|
+
bundle = auth.device_login(on_user_code=on_user_code)
|
|
31
|
+
except auth.AuthError as exc:
|
|
32
|
+
console.print(f"[red]Login failed:[/red] {exc}")
|
|
33
|
+
raise typer.Exit(code=1)
|
|
34
|
+
keyring_store.save(bundle)
|
|
35
|
+
console.print("[green]✓ Logged in.[/green]")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def logout() -> None:
|
|
39
|
+
"""Clear the cached token and any retired file-backed write state."""
|
|
40
|
+
keyring_store.clear()
|
|
41
|
+
write_gate.clear_legacy_state()
|
|
42
|
+
console.print("[green]✓ Logged out.[/green]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def whoami() -> None:
|
|
46
|
+
"""Show the identity behind the current token (no API call)."""
|
|
47
|
+
try:
|
|
48
|
+
client = PublinkerClient()
|
|
49
|
+
claims = client.whoami()
|
|
50
|
+
except NotAuthenticatedError as exc:
|
|
51
|
+
console.print(f"[red]{exc}[/red]")
|
|
52
|
+
raise typer.Exit(code=1)
|
|
53
|
+
|
|
54
|
+
table = Table(show_header=False, box=None)
|
|
55
|
+
table.add_column(style="cyan", no_wrap=True)
|
|
56
|
+
table.add_column()
|
|
57
|
+
for key in ("email", "name", "sub", "nickname", "email_verified"):
|
|
58
|
+
if key in claims:
|
|
59
|
+
table.add_row(key, str(claims[key]))
|
|
60
|
+
|
|
61
|
+
selected = state.active_site()
|
|
62
|
+
if selected:
|
|
63
|
+
table.add_row("active site", selected)
|
|
64
|
+
else:
|
|
65
|
+
table.add_row(
|
|
66
|
+
"active site",
|
|
67
|
+
"[dim]none (run `publinker use <domain>` to set one)[/dim]",
|
|
68
|
+
)
|
|
69
|
+
console.print(table)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""`publinker site report list-options` — quick reference for report filters.
|
|
2
|
+
|
|
3
|
+
The `/site/{site}/report` endpoint's `platform` and `dimensions_keys` filters
|
|
4
|
+
have no enum on the server (whatever the ETL bucketed by, the API will look
|
|
5
|
+
up). This command surfaces the values we've actually seen in the codebase
|
|
6
|
+
and verified against prod, so new users don't have to fish in DDB.
|
|
7
|
+
|
|
8
|
+
If the set grows or shifts, update the constants here — they're
|
|
9
|
+
intentionally hand-curated rather than introspected, because the API
|
|
10
|
+
itself has no listing endpoint to query.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# (platform, source / extractor, account_id format example)
|
|
22
|
+
_PLATFORMS: tuple[tuple[str, str, str], ...] = (
|
|
23
|
+
(
|
|
24
|
+
"google_admanager",
|
|
25
|
+
"Google Ad Manager reporting API",
|
|
26
|
+
"12345678 (GAM network code)",
|
|
27
|
+
),
|
|
28
|
+
(
|
|
29
|
+
"google_ads",
|
|
30
|
+
"Google Ads API",
|
|
31
|
+
"1234567890 (Ads customer id)",
|
|
32
|
+
),
|
|
33
|
+
(
|
|
34
|
+
"active_campaign",
|
|
35
|
+
"ActiveCampaign API",
|
|
36
|
+
"<AC account>",
|
|
37
|
+
),
|
|
38
|
+
(
|
|
39
|
+
"google_postmaster",
|
|
40
|
+
"Google Postmaster Tools",
|
|
41
|
+
"<sender domain>",
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"escalated",
|
|
45
|
+
"Publinker escalated-issues pipeline",
|
|
46
|
+
"publinker",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# (dimensions_keys, what each row represents, typical platform)
|
|
52
|
+
_DIMENSIONS: tuple[tuple[str, str, str], ...] = (
|
|
53
|
+
("date", "one row per day (aggregate)", "any"),
|
|
54
|
+
(
|
|
55
|
+
"date,channel",
|
|
56
|
+
"one row per (day, traffic channel)",
|
|
57
|
+
"google_admanager / google_ads",
|
|
58
|
+
),
|
|
59
|
+
("date,channel,adunit", "one row per (day, channel, ad unit)", "google_admanager"),
|
|
60
|
+
(
|
|
61
|
+
"date,channel,country_code,country_name,url,adunit",
|
|
62
|
+
"full detail — one row per URL × adunit per day",
|
|
63
|
+
"google_admanager",
|
|
64
|
+
),
|
|
65
|
+
("date,domain,device,country", "escalated-issues breakdown", "escalated"),
|
|
66
|
+
(
|
|
67
|
+
"date,channel,country_code,country_name,url,adunit:report_type=adspeed",
|
|
68
|
+
"adspeed-style report (separate fact set)",
|
|
69
|
+
"google_admanager",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_options() -> None:
|
|
75
|
+
"""Print the verified platform + dimensions_keys values."""
|
|
76
|
+
platforms = Table(
|
|
77
|
+
title="platform values (publinker site report get --platform <X>)"
|
|
78
|
+
)
|
|
79
|
+
platforms.add_column("value", style="cyan")
|
|
80
|
+
platforms.add_column("source")
|
|
81
|
+
platforms.add_column("account_id example", style="dim")
|
|
82
|
+
for row in _PLATFORMS:
|
|
83
|
+
platforms.add_row(*row)
|
|
84
|
+
console.print(platforms)
|
|
85
|
+
|
|
86
|
+
dims = Table(
|
|
87
|
+
title="dimensions_keys values (publinker site report get --dimensions-keys <X>)"
|
|
88
|
+
)
|
|
89
|
+
dims.add_column("value", style="cyan", overflow="fold")
|
|
90
|
+
dims.add_column("granularity")
|
|
91
|
+
dims.add_column("platform", style="dim")
|
|
92
|
+
for row in _DIMENSIONS:
|
|
93
|
+
dims.add_row(*row)
|
|
94
|
+
console.print(dims)
|
|
95
|
+
|
|
96
|
+
console.print(
|
|
97
|
+
"\n[dim]The set is what we've verified against prod; the API itself "
|
|
98
|
+
"has no listing endpoint. If you find a working combination not "
|
|
99
|
+
"listed here, please add it to cli/publinker_cli/commands/report_options.py.[/dim]"
|
|
100
|
+
)
|