granola-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ lint:
14
+ name: ruff
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+ with:
21
+ enable-cache: true
22
+ - name: Sync (with dev group)
23
+ run: uv sync --group dev
24
+ - name: Ruff lint
25
+ run: uv run ruff check --output-format=github .
26
+
27
+ test:
28
+ name: pytest (${{ matrix.os }} / py${{ matrix.python-version }})
29
+ runs-on: ${{ matrix.os }}
30
+ strategy:
31
+ fail-fast: false
32
+ matrix:
33
+ os: [ubuntu-latest, windows-latest]
34
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - name: Install uv
38
+ uses: astral-sh/setup-uv@v5
39
+ with:
40
+ enable-cache: true
41
+ python-version: ${{ matrix.python-version }}
42
+ - name: Sync (with dev group)
43
+ run: uv sync --group dev
44
+ - name: Run tests
45
+ run: uv run pytest
@@ -0,0 +1,55 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch: # manual run -> TestPyPI (for testing the publish flow)
7
+
8
+ jobs:
9
+ build:
10
+ name: build artifacts
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v5
16
+ - name: Build sdist + wheel
17
+ run: uv build
18
+ - name: Check metadata renders
19
+ run: uvx twine check dist/*
20
+ - uses: actions/upload-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ testpypi:
26
+ name: publish to TestPyPI
27
+ needs: build
28
+ if: github.event_name == 'workflow_dispatch'
29
+ runs-on: ubuntu-latest
30
+ environment: testpypi
31
+ permissions:
32
+ id-token: write # OIDC -> trusted publishing, no stored token
33
+ steps:
34
+ - uses: actions/download-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+ - uses: pypa/gh-action-pypi-publish@release/v1
39
+ with:
40
+ repository-url: https://test.pypi.org/legacy/
41
+
42
+ pypi:
43
+ name: publish to PyPI
44
+ needs: build
45
+ if: startsWith(github.ref, 'refs/tags/v')
46
+ runs-on: ubuntu-latest
47
+ environment: pypi
48
+ permissions:
49
+ id-token: write
50
+ steps:
51
+ - uses: actions/download-artifact@v4
52
+ with:
53
+ name: dist
54
+ path: dist/
55
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+
9
+ # Secrets — never commit decrypted creds or tokens
10
+ *.enc
11
+ *.dek
12
+ *.bak-*
13
+ *credentials*.json
14
+ *token*.json
15
+ .env*
16
+
17
+ # Local data / scratch
18
+ *.db
19
+ notes/
20
+ tmp/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Granola CLI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: granola-cli
3
+ Version: 0.1.0
4
+ Summary: One CLI for Granola: decrypt on-disk creds and drive the documented internal API (read/share/edit notes).
5
+ Project-URL: Homepage, https://github.com/xuelongmu/granola-cli
6
+ Project-URL: Repository, https://github.com/xuelongmu/granola-cli
7
+ Project-URL: Issues, https://github.com/xuelongmu/granola-cli/issues
8
+ Author: Granola CLI contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,granola,meeting-notes,transcripts
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: cryptography>=42
19
+ Requires-Dist: httpx>=0.27
20
+ Description-Content-Type: text/markdown
21
+
22
+ # granola
23
+
24
+ One CLI for [Granola](https://granola.ai) on **Windows & macOS** — using Granola's own
25
+ on-disk session, no API key, no password prompt. It covers two things:
26
+
27
+ 1. **Credentials** — decrypt the on-disk chain (Windows DPAPI / macOS Keychain → DEK →
28
+ cred file), auto-**refresh** the token (single-use rotation, with safe write-back),
29
+ print it, or export a portable **session file** for headless/Linux use.
30
+ 2. **The documented internal API** for a note — read, share, edit — plus a generic
31
+ `call` for any of the ~392 internal endpoints.
32
+
33
+ > Unofficial. Uses the internal `api.granola.ai` surface the desktop app uses; it can
34
+ > change without notice. Credential decrypt works on **Windows** (DPAPI) and **macOS**
35
+ > (Keychain); the API layer is portable once you have a token.
36
+
37
+ ## Install
38
+
39
+ ```powershell
40
+ uv tool install . # or: pipx install .
41
+ granola auth status
42
+ ```
43
+
44
+ Requires Python ≥ 3.10. Dependencies: `httpx`, `cryptography`.
45
+
46
+ ## Usage
47
+
48
+ ```text
49
+ # auth / engine
50
+ granola auth status # token status (no secrets; --include-secrets to show)
51
+ granola auth token # print a valid access token
52
+ granola auth refresh # force-refresh the selected source
53
+ granola auth export session.json # write a portable, refreshable session file (0600)
54
+ granola routes [filter] # endpoint name -> URL map
55
+ granola call <endpoint> --body '{"limit":5}' # any endpoint, raw
56
+
57
+ # read
58
+ granola notes --limit 20 # recent notes
59
+ granola get <id> # full ~50-field record (--json for all)
60
+ granola meta <id> # creator / attendees / conferencing
61
+ granola transcript <id> # transcript as markdown
62
+ granola panels <id> # AI summary panels
63
+
64
+ # share / access
65
+ granola who <id> # who has access (+ user_ids)
66
+ granola share <id> --email teammate@example.com --name "Teammate" # add collaborator
67
+ granola unshare <id> --email teammate@example.com # revoke (no email sent)
68
+ granola role <id> --user <user_id> --role viewer # change role
69
+ granola folder-who "Team Notes" # who has folder-level access
70
+ granola share-folder "Team Notes" --email teammate@example.com # folder ACL (existing users; inherited access)
71
+ granola share-folder "Team Notes" --email teammate@example.com --per-note # invite + direct access on each note
72
+ granola unshare-folder "Team Notes" --email teammate@example.com # revoke folder-level access
73
+
74
+ # edit
75
+ granola update <id> --title "New title"
76
+ granola delete <id> --yes # PERMANENT hard delete
77
+ ```
78
+
79
+ Roles: `owner` · `collaborator` · `viewer`.
80
+
81
+ See [`docs/api-gotchas.md`](docs/api-gotchas.md) for endpoint quirks baked into the
82
+ typed verbs.
83
+
84
+ ## Headless / portable sessions
85
+
86
+ Credential decrypt needs the Keychain (macOS) or DPAPI (Windows), so it only runs on
87
+ the machine you signed in on. To drive the API from a headless Linux box or CI, export
88
+ a **session file** once and carry it over:
89
+
90
+ ```bash
91
+ # On your macOS/Windows machine (where Granola is signed in):
92
+ granola auth export ~/session.json # minimal, refreshable, written 0600
93
+
94
+ # Sign OUT of the Granola desktop app, so only this session holds the refresh token.
95
+ # (Desktop + session sharing one single-use refresh token will fight and log each
96
+ # other out on rotation.)
97
+
98
+ # On the headless box:
99
+ export GRANOLA_SESSION=~/session.json
100
+ granola notes --limit 20 # refreshes + writes back to the file in place
101
+ ```
102
+
103
+ Every command takes global auth options (before the command) that pick the token source:
104
+
105
+ ```text
106
+ --email EMAIL select an account from local desktop credentials
107
+ --session PATH use a refreshable session file
108
+ --access-token TOKEN use this bearer token directly (no refresh)
109
+ --no-refresh never auto-refresh
110
+ ```
111
+
112
+ Environment equivalents: `GRANOLA_SESSION`, `GRANOLA_ACCESS_TOKEN`.
113
+
114
+ **Precedence:** a refreshable session (`--session` / `GRANOLA_SESSION`) beats a static
115
+ token (`--access-token` / `GRANOLA_ACCESS_TOKEN`); a flag beats its env var; the desktop
116
+ store is the fallback. If both a static token and a session are set, the session wins and
117
+ the CLI warns — a static token can't refresh and would silently go stale.
118
+
119
+ Use `--access-token` (or `granola auth export --no-refresh-token`) for short-lived CI where
120
+ you don't want a long-lived rotating secret on the box.
121
+
122
+ If a session's refresh token ever dies (the desktop rotated it, or it was revoked), you
123
+ can't re-bootstrap headlessly — re-run `granola auth export` on your macOS/Windows machine
124
+ and copy the file over.
125
+
126
+ ## Platforms
127
+
128
+ | | Windows | macOS |
129
+ |---|---|---|
130
+ | Data dir | `%APPDATA%\Granola` | `~/Library/Application Support/Granola` |
131
+ | Key source | `Local State` → **DPAPI** (CurrentUser) | login **Keychain** item `Granola Safe Storage` / `Granola Key` |
132
+ | `storage.dek` unwrap | AES-256-GCM (Chromium key) | AES-128-CBC (PBKDF2 `saltysalt`/1003 — Electron safeStorage) |
133
+ | Cred file | `stored-accounts.json.enc` (`accounts[].tokens`) | `supabase.json.enc` (`workos_tokens`) |
134
+ | Final decrypt | AES-256-GCM(DEK) | AES-256-GCM(DEK) |
135
+
136
+ The decrypt must run as the logged-in user (DPAPI / Keychain are user-scoped). On macOS
137
+ the **first** run may show a Keychain access prompt for the `Granola Safe Storage` item —
138
+ allow it. The macOS crypto is verified against
139
+ [harperreed/muesli](https://github.com/harperreed/muesli)'s known-good vectors
140
+ (`tests/test_macos_crypto.py`).
141
+
142
+ ## Layout
143
+
144
+ ```
145
+ granola/
146
+ config, crypto, _http, store # engine: decrypt the on-disk cred chain
147
+ auth.py token primitives: refresh exchange, expiry, status (persistence-free)
148
+ sources.py token sources (desktop / session-file / static) + precedence
149
+ routes, client # endpoint resolution + API client
150
+ notes.py read ops (list/get/meta/transcript/panels)
151
+ sharing.py collaborators (who/share/unshare/role/share-folder)
152
+ editing.py update-document / hard-delete
153
+ cli.py the `granola` command
154
+ ```
155
+
156
+ ## License
157
+
158
+ MIT.
@@ -0,0 +1,137 @@
1
+ # granola
2
+
3
+ One CLI for [Granola](https://granola.ai) on **Windows & macOS** — using Granola's own
4
+ on-disk session, no API key, no password prompt. It covers two things:
5
+
6
+ 1. **Credentials** — decrypt the on-disk chain (Windows DPAPI / macOS Keychain → DEK →
7
+ cred file), auto-**refresh** the token (single-use rotation, with safe write-back),
8
+ print it, or export a portable **session file** for headless/Linux use.
9
+ 2. **The documented internal API** for a note — read, share, edit — plus a generic
10
+ `call` for any of the ~392 internal endpoints.
11
+
12
+ > Unofficial. Uses the internal `api.granola.ai` surface the desktop app uses; it can
13
+ > change without notice. Credential decrypt works on **Windows** (DPAPI) and **macOS**
14
+ > (Keychain); the API layer is portable once you have a token.
15
+
16
+ ## Install
17
+
18
+ ```powershell
19
+ uv tool install . # or: pipx install .
20
+ granola auth status
21
+ ```
22
+
23
+ Requires Python ≥ 3.10. Dependencies: `httpx`, `cryptography`.
24
+
25
+ ## Usage
26
+
27
+ ```text
28
+ # auth / engine
29
+ granola auth status # token status (no secrets; --include-secrets to show)
30
+ granola auth token # print a valid access token
31
+ granola auth refresh # force-refresh the selected source
32
+ granola auth export session.json # write a portable, refreshable session file (0600)
33
+ granola routes [filter] # endpoint name -> URL map
34
+ granola call <endpoint> --body '{"limit":5}' # any endpoint, raw
35
+
36
+ # read
37
+ granola notes --limit 20 # recent notes
38
+ granola get <id> # full ~50-field record (--json for all)
39
+ granola meta <id> # creator / attendees / conferencing
40
+ granola transcript <id> # transcript as markdown
41
+ granola panels <id> # AI summary panels
42
+
43
+ # share / access
44
+ granola who <id> # who has access (+ user_ids)
45
+ granola share <id> --email teammate@example.com --name "Teammate" # add collaborator
46
+ granola unshare <id> --email teammate@example.com # revoke (no email sent)
47
+ granola role <id> --user <user_id> --role viewer # change role
48
+ granola folder-who "Team Notes" # who has folder-level access
49
+ granola share-folder "Team Notes" --email teammate@example.com # folder ACL (existing users; inherited access)
50
+ granola share-folder "Team Notes" --email teammate@example.com --per-note # invite + direct access on each note
51
+ granola unshare-folder "Team Notes" --email teammate@example.com # revoke folder-level access
52
+
53
+ # edit
54
+ granola update <id> --title "New title"
55
+ granola delete <id> --yes # PERMANENT hard delete
56
+ ```
57
+
58
+ Roles: `owner` · `collaborator` · `viewer`.
59
+
60
+ See [`docs/api-gotchas.md`](docs/api-gotchas.md) for endpoint quirks baked into the
61
+ typed verbs.
62
+
63
+ ## Headless / portable sessions
64
+
65
+ Credential decrypt needs the Keychain (macOS) or DPAPI (Windows), so it only runs on
66
+ the machine you signed in on. To drive the API from a headless Linux box or CI, export
67
+ a **session file** once and carry it over:
68
+
69
+ ```bash
70
+ # On your macOS/Windows machine (where Granola is signed in):
71
+ granola auth export ~/session.json # minimal, refreshable, written 0600
72
+
73
+ # Sign OUT of the Granola desktop app, so only this session holds the refresh token.
74
+ # (Desktop + session sharing one single-use refresh token will fight and log each
75
+ # other out on rotation.)
76
+
77
+ # On the headless box:
78
+ export GRANOLA_SESSION=~/session.json
79
+ granola notes --limit 20 # refreshes + writes back to the file in place
80
+ ```
81
+
82
+ Every command takes global auth options (before the command) that pick the token source:
83
+
84
+ ```text
85
+ --email EMAIL select an account from local desktop credentials
86
+ --session PATH use a refreshable session file
87
+ --access-token TOKEN use this bearer token directly (no refresh)
88
+ --no-refresh never auto-refresh
89
+ ```
90
+
91
+ Environment equivalents: `GRANOLA_SESSION`, `GRANOLA_ACCESS_TOKEN`.
92
+
93
+ **Precedence:** a refreshable session (`--session` / `GRANOLA_SESSION`) beats a static
94
+ token (`--access-token` / `GRANOLA_ACCESS_TOKEN`); a flag beats its env var; the desktop
95
+ store is the fallback. If both a static token and a session are set, the session wins and
96
+ the CLI warns — a static token can't refresh and would silently go stale.
97
+
98
+ Use `--access-token` (or `granola auth export --no-refresh-token`) for short-lived CI where
99
+ you don't want a long-lived rotating secret on the box.
100
+
101
+ If a session's refresh token ever dies (the desktop rotated it, or it was revoked), you
102
+ can't re-bootstrap headlessly — re-run `granola auth export` on your macOS/Windows machine
103
+ and copy the file over.
104
+
105
+ ## Platforms
106
+
107
+ | | Windows | macOS |
108
+ |---|---|---|
109
+ | Data dir | `%APPDATA%\Granola` | `~/Library/Application Support/Granola` |
110
+ | Key source | `Local State` → **DPAPI** (CurrentUser) | login **Keychain** item `Granola Safe Storage` / `Granola Key` |
111
+ | `storage.dek` unwrap | AES-256-GCM (Chromium key) | AES-128-CBC (PBKDF2 `saltysalt`/1003 — Electron safeStorage) |
112
+ | Cred file | `stored-accounts.json.enc` (`accounts[].tokens`) | `supabase.json.enc` (`workos_tokens`) |
113
+ | Final decrypt | AES-256-GCM(DEK) | AES-256-GCM(DEK) |
114
+
115
+ The decrypt must run as the logged-in user (DPAPI / Keychain are user-scoped). On macOS
116
+ the **first** run may show a Keychain access prompt for the `Granola Safe Storage` item —
117
+ allow it. The macOS crypto is verified against
118
+ [harperreed/muesli](https://github.com/harperreed/muesli)'s known-good vectors
119
+ (`tests/test_macos_crypto.py`).
120
+
121
+ ## Layout
122
+
123
+ ```
124
+ granola/
125
+ config, crypto, _http, store # engine: decrypt the on-disk cred chain
126
+ auth.py token primitives: refresh exchange, expiry, status (persistence-free)
127
+ sources.py token sources (desktop / session-file / static) + precedence
128
+ routes, client # endpoint resolution + API client
129
+ notes.py read ops (list/get/meta/transcript/panels)
130
+ sharing.py collaborators (who/share/unshare/role/share-folder)
131
+ editing.py update-document / hard-delete
132
+ cli.py the `granola` command
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT.
@@ -0,0 +1,14 @@
1
+ # API gotchas
2
+
3
+ These quirks are baked into the typed verbs so callers do not have to rediscover
4
+ them by hitting API errors.
5
+
6
+ - **`share`** -> `add-users-to-document` wants `names` as an **`{email: name}` object map**,
7
+ not an array. Sending an array can make the server return `500`.
8
+ - **`update`** -> `update-document` keys the note as **`id`**, not `document_id`.
9
+ Sending `document_id` returns `400 "Missing document ID"`.
10
+ - **`get`** -> the full single-note record comes from `get-documents-batch`
11
+ (`{document_ids: [...]}`), not a singular `get-document`.
12
+
13
+ Full request/response shapes are documented in `docs/granola-api.md` in the companion
14
+ credential-decrypt research repo.
@@ -0,0 +1,61 @@
1
+ """Granola — decrypt on-disk credentials, auto-refresh, and drive the documented internal API.
2
+
3
+ Quick start::
4
+
5
+ from granola import GranolaClient, notes, sharing
6
+ client = GranolaClient()
7
+ me = client.invoke("get-user-info")
8
+ recent = notes.list_notes(client, limit=10)
9
+ sharing.add_collaborator(client, "<doc-id>", "person@example.com", name="Person")
10
+
11
+ Headless / portable auth::
12
+
13
+ from granola import GranolaClient, SessionFileSource, Config
14
+ cfg = Config()
15
+ client = GranolaClient(cfg, source=SessionFileSource(cfg, "session.json"))
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from . import editing, notes, sharing
20
+ from .auth import (
21
+ RefreshRevoked,
22
+ format_token_status,
23
+ refresh_exchange,
24
+ token_is_expiring,
25
+ )
26
+ from .client import GranolaClient
27
+ from .config import Config
28
+ from .routes import load_routes, resolve_endpoint
29
+ from .sources import (
30
+ DesktopStoreSource,
31
+ SessionFileSource,
32
+ StaticTokenSource,
33
+ TokenSource,
34
+ create_session_file,
35
+ resolve_source,
36
+ )
37
+ from .store import get_dek, read_store, save_store
38
+
39
+ __version__ = "0.1.0"
40
+ __all__ = [
41
+ "Config",
42
+ "GranolaClient",
43
+ "TokenSource",
44
+ "DesktopStoreSource",
45
+ "SessionFileSource",
46
+ "StaticTokenSource",
47
+ "resolve_source",
48
+ "create_session_file",
49
+ "refresh_exchange",
50
+ "format_token_status",
51
+ "token_is_expiring",
52
+ "RefreshRevoked",
53
+ "load_routes",
54
+ "resolve_endpoint",
55
+ "read_store",
56
+ "save_store",
57
+ "get_dek",
58
+ "notes",
59
+ "sharing",
60
+ "editing",
61
+ ]
@@ -0,0 +1,49 @@
1
+ """HTTP helpers: base headers + redirect-safe request via httpx."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import sys
6
+
7
+ import httpx
8
+
9
+
10
+ def base_headers(cfg, access_token: str | None = None, additional: dict | None = None) -> dict:
11
+ headers = {
12
+ "X-Client-Version": cfg.client_version,
13
+ "X-Granola-Platform": cfg.platform,
14
+ "Accept": "application/json",
15
+ "User-Agent": f"Granola/{cfg.client_version}",
16
+ }
17
+ if access_token:
18
+ headers["Authorization"] = f"Bearer {access_token}"
19
+ if additional:
20
+ headers.update(additional)
21
+ return headers
22
+
23
+
24
+ def request(method: str, url: str, *, json_body=None, headers: dict | None = None,
25
+ timeout: float = 60.0) -> httpx.Response:
26
+ # follow_redirects=True keeps the POST body across 307/308 (httpx, unlike the
27
+ # old PowerShell Invoke-RestMethod, does this correctly).
28
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
29
+ return client.request(method.upper(), url, json=json_body, headers=headers)
30
+
31
+
32
+ def granola_running() -> bool:
33
+ """Best-effort check whether the desktop app is running (Windows/macOS)."""
34
+ try:
35
+ if sys.platform == "win32":
36
+ out = subprocess.run(
37
+ ["tasklist", "/FI", "IMAGENAME eq Granola.exe", "/NH"],
38
+ capture_output=True, text=True, timeout=5,
39
+ )
40
+ return "Granola.exe" in out.stdout
41
+ if sys.platform == "darwin":
42
+ out = subprocess.run(
43
+ ["/usr/bin/pgrep", "-x", "Granola"],
44
+ capture_output=True, text=True, timeout=5,
45
+ )
46
+ return out.returncode == 0 and bool(out.stdout.strip())
47
+ except Exception:
48
+ return False
49
+ return False
@@ -0,0 +1,103 @@
1
+ """Token primitives: the refresh HTTP exchange, expiry math, account selection,
2
+ and status formatting.
3
+
4
+ These are deliberately persistence-free. *Where* a refreshed token gets written
5
+ back (the encrypted desktop store vs. a portable session file) lives in
6
+ ``sources.py`` — this module only knows how to talk to the refresh endpoint and
7
+ how to reason about a token dict.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from datetime import datetime, timezone
13
+
14
+ from ._http import base_headers, request
15
+ from .config import Config
16
+
17
+
18
+ class RefreshRevoked(RuntimeError):
19
+ """The refresh token was rejected (revoked or already rotated away).
20
+
21
+ Carries a source-specific, human-readable recovery message — the desktop and
22
+ session sources re-raise it with the right re-auth instructions.
23
+ """
24
+
25
+
26
+ def _now_ms() -> int:
27
+ return int(time.time() * 1000)
28
+
29
+
30
+ def token_is_expiring(token: dict, skew_ms: int) -> bool:
31
+ expiry_ms = int(token["obtained_at"]) + int(token["expires_in"]) * 1000
32
+ return (expiry_ms - _now_ms()) < skew_ms
33
+
34
+
35
+ def select_account(store, email: str | None = None):
36
+ if email:
37
+ for acct in store.accounts:
38
+ if acct.get("email") == email:
39
+ return acct
40
+ raise ValueError(f"No stored account with email '{email}'.")
41
+ return store.accounts[0]
42
+
43
+
44
+ def refresh_exchange(cfg: Config, tok: dict) -> dict:
45
+ """POST the refresh token and return a *new* token dict. Pure — no write-back.
46
+
47
+ Raises ``RefreshRevoked`` on 401 (revoked/rotated) and ``RuntimeError`` on any
48
+ other non-2xx. The returned dict is a copy of ``tok`` with the rotated fields
49
+ applied, so callers decide where to persist it.
50
+ """
51
+ if not tok.get("refresh_token"):
52
+ raise ValueError("No refresh_token available to refresh.")
53
+
54
+ headers = base_headers(cfg, tok["access_token"])
55
+ resp = request("POST", cfg.refresh_url,
56
+ json_body={"refresh_token": tok["refresh_token"]},
57
+ headers=headers, timeout=cfg.timeout)
58
+
59
+ if resp.status_code == 401:
60
+ kind = None
61
+ try:
62
+ kind = resp.json().get("error")
63
+ except Exception:
64
+ pass
65
+ raise RefreshRevoked(f"Refresh rejected (401{': ' + kind if kind else ''}).")
66
+ if resp.status_code >= 400:
67
+ raise RuntimeError(f"Refresh failed: HTTP {resp.status_code}. {resp.text[:300]}")
68
+
69
+ data = resp.json()
70
+ if not data.get("access_token"):
71
+ raise RuntimeError("Refresh OK but no access_token in response.")
72
+
73
+ new = dict(tok)
74
+ new["access_token"] = data["access_token"]
75
+ new["expires_in"] = data.get("expires_in", tok.get("expires_in"))
76
+ for key in ("refresh_token", "token_type", "session_id", "sign_in_method"):
77
+ if data.get(key):
78
+ new[key] = data[key]
79
+ new["obtained_at"] = _now_ms()
80
+ return new
81
+
82
+
83
+ def format_token_status(tok: dict, skew_ms: int, include_secrets: bool = False) -> dict:
84
+ """A no-secrets status view of one token dict (secrets gated behind the flag)."""
85
+ obt_ms = int(tok["obtained_at"])
86
+ obtained = datetime.fromtimestamp(obt_ms / 1000, tz=timezone.utc)
87
+ expiry = datetime.fromtimestamp(
88
+ (obt_ms + int(tok["expires_in"]) * 1000) / 1000, tz=timezone.utc
89
+ )
90
+ now = datetime.now(timezone.utc)
91
+ info = {
92
+ "token_type": tok.get("token_type"),
93
+ "sign_in_method": tok.get("sign_in_method"),
94
+ "obtained_at_utc": obtained.isoformat(),
95
+ "expiry_utc": expiry.isoformat(),
96
+ "expired": now > expiry,
97
+ "expiring_soon": token_is_expiring(tok, skew_ms),
98
+ "minutes_left": round((expiry - now).total_seconds() / 60, 1),
99
+ }
100
+ if include_secrets:
101
+ info["access_token"] = tok.get("access_token")
102
+ info["refresh_token"] = tok.get("refresh_token")
103
+ return info