figma-cli 0.3.0__tar.gz → 0.3.1__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.
- {figma_cli-0.3.0 → figma_cli-0.3.1}/.github/workflows/ci.yml +7 -2
- {figma_cli-0.3.0 → figma_cli-0.3.1}/PKG-INFO +28 -5
- {figma_cli-0.3.0 → figma_cli-0.3.1}/README.md +27 -4
- {figma_cli-0.3.0 → figma_cli-0.3.1}/src/figma_cli/__init__.py +1 -1
- {figma_cli-0.3.0 → figma_cli-0.3.1}/src/figma_cli/cli.py +34 -3
- {figma_cli-0.3.0 → figma_cli-0.3.1}/src/figma_cli/client.py +93 -6
- figma_cli-0.3.1/src/figma_cli/login.py +92 -0
- figma_cli-0.3.1/src/figma_cli/oauth.py +449 -0
- {figma_cli-0.3.0 → figma_cli-0.3.1}/tests/test_cli.py +23 -0
- {figma_cli-0.3.0 → figma_cli-0.3.1}/tests/test_client.py +4 -1
- {figma_cli-0.3.0 → figma_cli-0.3.1}/tests/test_live.py +44 -2
- figma_cli-0.3.1/tests/test_oauth.py +630 -0
- figma_cli-0.3.0/src/figma_cli/login.py +0 -108
- {figma_cli-0.3.0 → figma_cli-0.3.1}/.github/workflows/release.yml +0 -0
- {figma_cli-0.3.0 → figma_cli-0.3.1}/.gitignore +0 -0
- {figma_cli-0.3.0 → figma_cli-0.3.1}/LICENSE +0 -0
- {figma_cli-0.3.0 → figma_cli-0.3.1}/pyproject.toml +0 -0
- {figma_cli-0.3.0 → figma_cli-0.3.1}/tests/__init__.py +0 -0
- {figma_cli-0.3.0 → figma_cli-0.3.1}/tests/conftest.py +0 -0
|
@@ -30,14 +30,19 @@ jobs:
|
|
|
30
30
|
run: pytest tests/
|
|
31
31
|
|
|
32
32
|
live:
|
|
33
|
-
# Runs against the real Figma API when the FIGMA_TOKEN
|
|
34
|
-
#
|
|
33
|
+
# Runs against the real Figma API when the FIGMA_TOKEN (and, for OAuth, the
|
|
34
|
+
# FIGMA_OAUTH_REFRESH_TOKEN, FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET) secrets are
|
|
35
|
+
# configured; the tests skip cleanly when they are absent (forks, unconfigured).
|
|
35
36
|
runs-on: ubuntu-latest
|
|
36
37
|
needs: test
|
|
37
38
|
env:
|
|
38
39
|
FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
|
|
39
40
|
FIGMA_TEST_FILE_KEY: ${{ vars.FIGMA_TEST_FILE_KEY }}
|
|
40
41
|
FIGMA_TEST_NODE_ID: ${{ vars.FIGMA_TEST_NODE_ID }}
|
|
42
|
+
# A dedicated test OAuth app: each refresh invalidates its last access token.
|
|
43
|
+
FIGMA_OAUTH_REFRESH_TOKEN: ${{ secrets.FIGMA_OAUTH_REFRESH_TOKEN }}
|
|
44
|
+
FIGMA_CLIENT_ID: ${{ secrets.FIGMA_CLIENT_ID }}
|
|
45
|
+
FIGMA_CLIENT_SECRET: ${{ secrets.FIGMA_CLIENT_SECRET }}
|
|
41
46
|
steps:
|
|
42
47
|
- uses: actions/checkout@v5
|
|
43
48
|
with:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: figma-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Headless Figma CLI for AI coding agents and automated design inspection.
|
|
5
5
|
Project-URL: Homepage, https://github.com/imperfect-co/figma-cli
|
|
6
6
|
Project-URL: Issues, https://github.com/imperfect-co/figma-cli/issues
|
|
@@ -49,7 +49,7 @@ The npm package `silships/figma-cli` also installs a `figma-cli` executable. If
|
|
|
49
49
|
|
|
50
50
|
## Usage
|
|
51
51
|
|
|
52
|
-
Every command talks to the Figma REST API with a personal access token. Create
|
|
52
|
+
Every command talks to the Figma REST API with either a personal access token or an OAuth token from your own Figma OAuth app (see [OAuth login](#oauth-login)). Create a personal access token under Figma account settings (Security > Personal access tokens) with these scopes, the minimum the commands below need per [Figma's scope reference](https://developers.figma.com/docs/rest-api/scopes/):
|
|
53
53
|
|
|
54
54
|
| Scope | Used by |
|
|
55
55
|
| --- | --- |
|
|
@@ -68,20 +68,43 @@ figma auth login --token - < token.txt # same, explicit
|
|
|
68
68
|
|
|
69
69
|
`auth login` validates the token against `GET /v1/me` before writing anything. A rejected token exits 3 and leaves any existing token file untouched. A valid one is written to `~/.config/figma/token` with mode `0600` (its directory `0700`), through a temporary file renamed into place, so a failed write keeps the previous token, and the command reports the authenticated `id`, `handle` and `email` plus the file path. Prefer stdin over `--token <value>`, which exposes the token in process listings and shell history. Empty stdin exits 2. Add `--no-browser` to skip opening the settings page.
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
`auth login` takes this personal access token path whenever `--token` is given, stdin is piped, or no OAuth client credentials are configured. Saving a personal access token also removes `~/.config/figma/token.json`, so stale OAuth tokens never shadow it.
|
|
72
|
+
|
|
73
|
+
Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored files whenever it is set and non-empty. A value starting `figu_` (an OAuth access token) is sent as `Authorization: Bearer`, anything else as `X-Figma-Token`:
|
|
72
74
|
|
|
73
75
|
```sh
|
|
74
76
|
export FIGMA_TOKEN=figd_...
|
|
75
77
|
export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
With
|
|
80
|
+
Credentials resolve in this order: `FIGMA_TOKEN`, then `~/.config/figma/token.json` (OAuth), then `~/.config/figma/token` (personal access token). With none of them, commands fail with `{"error": "missing_token"}` and exit code 3.
|
|
81
|
+
|
|
82
|
+
### OAuth login
|
|
83
|
+
|
|
84
|
+
figma-cli ships no OAuth app of its own: Figma authenticates the client with its secret on every token call, and a secret embedded in an open-source package is not a secret. Bring your own:
|
|
85
|
+
|
|
86
|
+
1. Create an OAuth app in the [Figma developer console](https://www.figma.com/developers/apps) and register the redirect URL `http://127.0.0.1:54321/callback`. Grant it the four scopes above.
|
|
87
|
+
2. Export its credentials and log in from an interactive terminal:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
export FIGMA_CLIENT_ID=...
|
|
91
|
+
export FIGMA_CLIENT_SECRET=...
|
|
92
|
+
figma auth login # opens the browser, waits on 127.0.0.1:54321
|
|
93
|
+
figma auth login --port 8765 # if you registered http://127.0.0.1:8765/callback instead
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--client-id` and `--client-secret` override the variables, but a literal secret shows in process listings and shell history. The flow uses PKCE (S256) and a random `state`; a callback with the wrong `state` is answered with HTTP 400 and ignored. Because Figma matches redirect URLs exactly, a busy port is not swapped for another one: the command exits 2 with `{"error": "port_unavailable"}`. Add `--no-browser` to print the authorization URL without opening it.
|
|
97
|
+
|
|
98
|
+
Over SSH, in a container, or anywhere the browser runs on another machine, its redirect to `127.0.0.1` never reaches figma-cli. While it waits, the command reads the terminal: after authorizing, copy the full URL from the browser's address bar (the page itself fails to load) and paste it at the `Paste the callback URL here` prompt. A `code=...&state=...` query string works too. The paste is held to the same checks as the browser redirect: the path must be `/callback` on the configured port and `state` must match, so a bare code without `state` is refused. A rejected paste is explained and the prompt returns while the loopback server keeps listening.
|
|
99
|
+
|
|
100
|
+
The access token is checked against `GET /v1/me`, then the access token, refresh token, expiry, client id and client secret are written to `~/.config/figma/token.json` (mode `0600`, directory `0700`, atomic replace). Figma access tokens last 90 days. When the stored one has expired, the next command refreshes it through `POST /v1/oauth/refresh` before running. The refresh holds an advisory lock on `~/.config/figma/token.json.lock` and re-reads the file once it has the lock, so concurrent commands refresh once rather than invalidating each other's tokens (Windows has no `fcntl`, so there the refresh runs unlocked). A refresh Figma rejects exits 3 with `{"error": "refresh_failed"}` rather than falling back to an older personal access token; run `figma auth login` again.
|
|
79
101
|
|
|
80
102
|
Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
|
|
81
103
|
|
|
82
104
|
| Command | Figma endpoint |
|
|
83
105
|
| --- | --- |
|
|
84
106
|
| `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
|
|
107
|
+
| `figma auth login [--client-id ID] [--client-secret SECRET] [--port PORT]` | OAuth: `POST /v1/oauth/token`, `GET /v1/me`, then writes `~/.config/figma/token.json` |
|
|
85
108
|
| `figma auth check` | `GET /v1/me` |
|
|
86
109
|
| `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
|
|
87
110
|
| `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
|
|
@@ -150,7 +173,7 @@ HTTP 429 is reported at once, never retried, so the caller decides when to try a
|
|
|
150
173
|
|
|
151
174
|
### Token safety
|
|
152
175
|
|
|
153
|
-
A request carrying `X-Figma-Token` follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the
|
|
176
|
+
A request carrying a credential (`X-Figma-Token`, `Authorization: Bearer`, or the `Authorization: Basic` client credentials on the OAuth token calls) follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the credential never reaches another host. Exported images are downloaded from the pre-signed URLs Figma returns with a separate request that carries no token; only that request follows redirects.
|
|
154
177
|
|
|
155
178
|
## Installation and quick run
|
|
156
179
|
|
|
@@ -24,7 +24,7 @@ The npm package `silships/figma-cli` also installs a `figma-cli` executable. If
|
|
|
24
24
|
|
|
25
25
|
## Usage
|
|
26
26
|
|
|
27
|
-
Every command talks to the Figma REST API with a personal access token. Create
|
|
27
|
+
Every command talks to the Figma REST API with either a personal access token or an OAuth token from your own Figma OAuth app (see [OAuth login](#oauth-login)). Create a personal access token under Figma account settings (Security > Personal access tokens) with these scopes, the minimum the commands below need per [Figma's scope reference](https://developers.figma.com/docs/rest-api/scopes/):
|
|
28
28
|
|
|
29
29
|
| Scope | Used by |
|
|
30
30
|
| --- | --- |
|
|
@@ -43,20 +43,43 @@ figma auth login --token - < token.txt # same, explicit
|
|
|
43
43
|
|
|
44
44
|
`auth login` validates the token against `GET /v1/me` before writing anything. A rejected token exits 3 and leaves any existing token file untouched. A valid one is written to `~/.config/figma/token` with mode `0600` (its directory `0700`), through a temporary file renamed into place, so a failed write keeps the previous token, and the command reports the authenticated `id`, `handle` and `email` plus the file path. Prefer stdin over `--token <value>`, which exposes the token in process listings and shell history. Empty stdin exits 2. Add `--no-browser` to skip opening the settings page.
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
`auth login` takes this personal access token path whenever `--token` is given, stdin is piped, or no OAuth client credentials are configured. Saving a personal access token also removes `~/.config/figma/token.json`, so stale OAuth tokens never shadow it.
|
|
47
|
+
|
|
48
|
+
Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored files whenever it is set and non-empty. A value starting `figu_` (an OAuth access token) is sent as `Authorization: Bearer`, anything else as `X-Figma-Token`:
|
|
47
49
|
|
|
48
50
|
```sh
|
|
49
51
|
export FIGMA_TOKEN=figd_...
|
|
50
52
|
export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
With
|
|
55
|
+
Credentials resolve in this order: `FIGMA_TOKEN`, then `~/.config/figma/token.json` (OAuth), then `~/.config/figma/token` (personal access token). With none of them, commands fail with `{"error": "missing_token"}` and exit code 3.
|
|
56
|
+
|
|
57
|
+
### OAuth login
|
|
58
|
+
|
|
59
|
+
figma-cli ships no OAuth app of its own: Figma authenticates the client with its secret on every token call, and a secret embedded in an open-source package is not a secret. Bring your own:
|
|
60
|
+
|
|
61
|
+
1. Create an OAuth app in the [Figma developer console](https://www.figma.com/developers/apps) and register the redirect URL `http://127.0.0.1:54321/callback`. Grant it the four scopes above.
|
|
62
|
+
2. Export its credentials and log in from an interactive terminal:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
export FIGMA_CLIENT_ID=...
|
|
66
|
+
export FIGMA_CLIENT_SECRET=...
|
|
67
|
+
figma auth login # opens the browser, waits on 127.0.0.1:54321
|
|
68
|
+
figma auth login --port 8765 # if you registered http://127.0.0.1:8765/callback instead
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`--client-id` and `--client-secret` override the variables, but a literal secret shows in process listings and shell history. The flow uses PKCE (S256) and a random `state`; a callback with the wrong `state` is answered with HTTP 400 and ignored. Because Figma matches redirect URLs exactly, a busy port is not swapped for another one: the command exits 2 with `{"error": "port_unavailable"}`. Add `--no-browser` to print the authorization URL without opening it.
|
|
72
|
+
|
|
73
|
+
Over SSH, in a container, or anywhere the browser runs on another machine, its redirect to `127.0.0.1` never reaches figma-cli. While it waits, the command reads the terminal: after authorizing, copy the full URL from the browser's address bar (the page itself fails to load) and paste it at the `Paste the callback URL here` prompt. A `code=...&state=...` query string works too. The paste is held to the same checks as the browser redirect: the path must be `/callback` on the configured port and `state` must match, so a bare code without `state` is refused. A rejected paste is explained and the prompt returns while the loopback server keeps listening.
|
|
74
|
+
|
|
75
|
+
The access token is checked against `GET /v1/me`, then the access token, refresh token, expiry, client id and client secret are written to `~/.config/figma/token.json` (mode `0600`, directory `0700`, atomic replace). Figma access tokens last 90 days. When the stored one has expired, the next command refreshes it through `POST /v1/oauth/refresh` before running. The refresh holds an advisory lock on `~/.config/figma/token.json.lock` and re-reads the file once it has the lock, so concurrent commands refresh once rather than invalidating each other's tokens (Windows has no `fcntl`, so there the refresh runs unlocked). A refresh Figma rejects exits 3 with `{"error": "refresh_failed"}` rather than falling back to an older personal access token; run `figma auth login` again.
|
|
54
76
|
|
|
55
77
|
Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
|
|
56
78
|
|
|
57
79
|
| Command | Figma endpoint |
|
|
58
80
|
| --- | --- |
|
|
59
81
|
| `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
|
|
82
|
+
| `figma auth login [--client-id ID] [--client-secret SECRET] [--port PORT]` | OAuth: `POST /v1/oauth/token`, `GET /v1/me`, then writes `~/.config/figma/token.json` |
|
|
60
83
|
| `figma auth check` | `GET /v1/me` |
|
|
61
84
|
| `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
|
|
62
85
|
| `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
|
|
@@ -125,7 +148,7 @@ HTTP 429 is reported at once, never retried, so the caller decides when to try a
|
|
|
125
148
|
|
|
126
149
|
### Token safety
|
|
127
150
|
|
|
128
|
-
A request carrying `X-Figma-Token` follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the
|
|
151
|
+
A request carrying a credential (`X-Figma-Token`, `Authorization: Bearer`, or the `Authorization: Basic` client credentials on the OAuth token calls) follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the credential never reaches another host. Exported images are downloaded from the pre-signed URLs Figma returns with a separate request that carries no token; only that request follows redirects.
|
|
129
152
|
|
|
130
153
|
## Installation and quick run
|
|
131
154
|
|
|
@@ -14,6 +14,7 @@ from typing import Any
|
|
|
14
14
|
from figma_cli import __version__
|
|
15
15
|
from figma_cli.client import FigmaClient, FigmaError, download
|
|
16
16
|
from figma_cli.login import login
|
|
17
|
+
from figma_cli.oauth import DEFAULT_PORT
|
|
17
18
|
|
|
18
19
|
# Most handlers take (client, args); those marked needs_client=False take (args).
|
|
19
20
|
Handler = Callable[..., tuple[Any, str]]
|
|
@@ -123,6 +124,13 @@ def _positive_int(value: str) -> int:
|
|
|
123
124
|
return number
|
|
124
125
|
|
|
125
126
|
|
|
127
|
+
def _port(value: str) -> int:
|
|
128
|
+
number = int(value)
|
|
129
|
+
if not 1 <= number <= 65535:
|
|
130
|
+
raise argparse.ArgumentTypeError("must be between 1 and 65535")
|
|
131
|
+
return number
|
|
132
|
+
|
|
133
|
+
|
|
126
134
|
def _node_list(value: str) -> list[str]:
|
|
127
135
|
nodes = [n.strip() for n in value.split(",") if n.strip()]
|
|
128
136
|
if not nodes:
|
|
@@ -138,7 +146,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
138
146
|
),
|
|
139
147
|
epilog=(
|
|
140
148
|
"Environment: FIGMA_TOKEN (optional when 'figma auth login' has stored"
|
|
141
|
-
" ~/.config/figma/token), FIGMA_API_BASE
|
|
149
|
+
" ~/.config/figma/token.json or ~/.config/figma/token), FIGMA_API_BASE"
|
|
150
|
+
" (optional), FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET (OAuth login)."
|
|
142
151
|
),
|
|
143
152
|
)
|
|
144
153
|
parser.add_argument(
|
|
@@ -157,7 +166,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
157
166
|
p = auth_cmds.add_parser(
|
|
158
167
|
"login",
|
|
159
168
|
parents=[common],
|
|
160
|
-
help=
|
|
169
|
+
help=(
|
|
170
|
+
"authorize via OAuth (with client credentials) or store a personal"
|
|
171
|
+
" access token, after validating it"
|
|
172
|
+
),
|
|
161
173
|
)
|
|
162
174
|
p.add_argument(
|
|
163
175
|
"--token",
|
|
@@ -169,7 +181,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
169
181
|
p.add_argument(
|
|
170
182
|
"--browser",
|
|
171
183
|
action=argparse.BooleanOptionalAction,
|
|
172
|
-
help="open Figma settings when
|
|
184
|
+
help=("open Figma settings or the OAuth page when interactive (default: open)"),
|
|
185
|
+
)
|
|
186
|
+
p.add_argument(
|
|
187
|
+
"--client-id", help="OAuth app client id (default: $FIGMA_CLIENT_ID)"
|
|
188
|
+
)
|
|
189
|
+
p.add_argument(
|
|
190
|
+
"--client-secret",
|
|
191
|
+
help=(
|
|
192
|
+
"OAuth app client secret (default: $FIGMA_CLIENT_SECRET); a literal"
|
|
193
|
+
" value shows in ps and shell history, so prefer the variable"
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
p.add_argument(
|
|
197
|
+
"--port",
|
|
198
|
+
type=_port,
|
|
199
|
+
default=DEFAULT_PORT,
|
|
200
|
+
help=(
|
|
201
|
+
"loopback port for the OAuth redirect http://127.0.0.1:PORT/callback"
|
|
202
|
+
f" (default: {DEFAULT_PORT})"
|
|
203
|
+
),
|
|
173
204
|
)
|
|
174
205
|
p.set_defaults(handler=_auth_login, needs_client=False)
|
|
175
206
|
|
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
Two transport rules hold for every request:
|
|
4
4
|
|
|
5
|
-
* A request carrying ``X-Figma-Token``
|
|
6
|
-
|
|
5
|
+
* A request carrying a credential (``X-Figma-Token`` for a personal access token,
|
|
6
|
+
``Authorization: Bearer`` for an OAuth token, ``Authorization: Basic`` for OAuth
|
|
7
|
+
client credentials) follows zero redirects. Any 3xx answer is refused before a
|
|
8
|
+
second request is made, so the credential never reaches another host.
|
|
7
9
|
* Rendered assets are fetched from their pre-signed URLs without the token.
|
|
10
|
+
|
|
11
|
+
Credentials resolve in this order: ``FIGMA_TOKEN``, then the OAuth tokens in
|
|
12
|
+
``~/.config/figma/token.json`` (refreshed when expired), then the personal access
|
|
13
|
+
token in ``~/.config/figma/token``.
|
|
8
14
|
"""
|
|
9
15
|
|
|
10
16
|
import email.utils
|
|
11
17
|
import json
|
|
12
18
|
import os
|
|
19
|
+
import secrets
|
|
13
20
|
import time
|
|
14
21
|
import urllib.error
|
|
15
22
|
import urllib.parse
|
|
@@ -19,6 +26,7 @@ from typing import Any
|
|
|
19
26
|
|
|
20
27
|
DEFAULT_API_BASE = "https://api.figma.com"
|
|
21
28
|
TOKEN_HEADER = "X-Figma-Token"
|
|
29
|
+
OAUTH_TOKEN_PREFIX = "figu_"
|
|
22
30
|
TIMEOUT_SECONDS = 60
|
|
23
31
|
EXIT_USAGE = 2
|
|
24
32
|
EXIT_API_ERROR = 3
|
|
@@ -42,6 +50,15 @@ class FigmaError(Exception):
|
|
|
42
50
|
self.payload = payload
|
|
43
51
|
|
|
44
52
|
|
|
53
|
+
class UsageError(FigmaError):
|
|
54
|
+
"""A local usage problem (bad input, busy port): exit 2."""
|
|
55
|
+
|
|
56
|
+
exit_code = EXIT_USAGE
|
|
57
|
+
|
|
58
|
+
def __init__(self, error: str, message: str):
|
|
59
|
+
super().__init__({"error": error, "message": message})
|
|
60
|
+
|
|
61
|
+
|
|
45
62
|
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
|
|
46
63
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
47
64
|
raise FigmaError(
|
|
@@ -96,10 +113,57 @@ def _network_error(err: OSError) -> FigmaError:
|
|
|
96
113
|
|
|
97
114
|
|
|
98
115
|
def token_path() -> Path:
|
|
99
|
-
"""Where ``figma auth login`` stores
|
|
116
|
+
"""Where ``figma auth login`` stores a personal access token."""
|
|
100
117
|
return Path.home() / ".config" / "figma" / "token"
|
|
101
118
|
|
|
102
119
|
|
|
120
|
+
def token_json_path() -> Path:
|
|
121
|
+
"""Where ``figma auth login`` stores OAuth tokens and client credentials."""
|
|
122
|
+
return token_path().with_name("token.json")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_oauth_token(token: str) -> bool:
|
|
126
|
+
"""True for a Figma OAuth access token, which travels as a Bearer token."""
|
|
127
|
+
return token.startswith(OAUTH_TOKEN_PREFIX)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def write_private(path: Path, text: str) -> None:
|
|
131
|
+
"""Write ``text`` to ``path`` at mode 0600, replacing it only once written.
|
|
132
|
+
|
|
133
|
+
The new file is created 0600 under a fresh name (O_EXCL, so never through a
|
|
134
|
+
planted file or symlink), then renamed over the old one: a failed write
|
|
135
|
+
leaves the previous file intact, and nothing is ever readable by others.
|
|
136
|
+
"""
|
|
137
|
+
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}")
|
|
138
|
+
try:
|
|
139
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
140
|
+
path.parent.chmod(0o700) # refuses a directory owned by someone else
|
|
141
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
142
|
+
try:
|
|
143
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
144
|
+
fh.write(text)
|
|
145
|
+
fh.flush()
|
|
146
|
+
os.fsync(fh.fileno())
|
|
147
|
+
os.replace(tmp, path)
|
|
148
|
+
_fsync_dir(path.parent)
|
|
149
|
+
except BaseException:
|
|
150
|
+
tmp.unlink(missing_ok=True)
|
|
151
|
+
raise
|
|
152
|
+
except OSError as err:
|
|
153
|
+
raise FigmaError({"error": "write_failed", "message": str(err)}) from None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _fsync_dir(directory: Path) -> None:
|
|
157
|
+
try:
|
|
158
|
+
dir_fd = os.open(directory, os.O_RDONLY)
|
|
159
|
+
try:
|
|
160
|
+
os.fsync(dir_fd)
|
|
161
|
+
finally:
|
|
162
|
+
os.close(dir_fd)
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
103
167
|
def read_token_file() -> str:
|
|
104
168
|
"""Return the stored token, or "" when no regular file holds one."""
|
|
105
169
|
path = token_path()
|
|
@@ -127,16 +191,25 @@ class FigmaClient:
|
|
|
127
191
|
token: str,
|
|
128
192
|
base_url: str = DEFAULT_API_BASE,
|
|
129
193
|
timeout: float = TIMEOUT_SECONDS,
|
|
194
|
+
bearer: bool | None = None,
|
|
130
195
|
):
|
|
131
196
|
self.token = token
|
|
197
|
+
self.bearer = is_oauth_token(token) if bearer is None else bearer
|
|
132
198
|
self.base_url = base_url.rstrip("/")
|
|
133
199
|
self.timeout = timeout
|
|
134
200
|
self._opener = urllib.request.build_opener(_RefuseRedirects)
|
|
135
201
|
|
|
136
202
|
@classmethod
|
|
137
203
|
def from_env(cls) -> "FigmaClient":
|
|
138
|
-
"""
|
|
139
|
-
token = os.environ.get("FIGMA_TOKEN", "").strip()
|
|
204
|
+
"""Resolve FIGMA_TOKEN, then stored OAuth tokens, then the stored PAT."""
|
|
205
|
+
token = os.environ.get("FIGMA_TOKEN", "").strip()
|
|
206
|
+
if token:
|
|
207
|
+
return cls(token, api_base())
|
|
208
|
+
if token_json_path().exists():
|
|
209
|
+
from figma_cli.oauth import fresh_access_token
|
|
210
|
+
|
|
211
|
+
return cls(fresh_access_token(), api_base(), bearer=True)
|
|
212
|
+
token = read_token_file()
|
|
140
213
|
if not token:
|
|
141
214
|
message = (
|
|
142
215
|
f"FIGMA_TOKEN is not set and {token_path()} holds no token;"
|
|
@@ -145,6 +218,11 @@ class FigmaClient:
|
|
|
145
218
|
raise FigmaError({"error": "missing_token", "message": message})
|
|
146
219
|
return cls(token, api_base())
|
|
147
220
|
|
|
221
|
+
def auth_headers(self) -> dict[str, str]:
|
|
222
|
+
if self.bearer:
|
|
223
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
224
|
+
return {TOKEN_HEADER: self.token}
|
|
225
|
+
|
|
148
226
|
def request(
|
|
149
227
|
self,
|
|
150
228
|
method: str,
|
|
@@ -155,7 +233,7 @@ class FigmaClient:
|
|
|
155
233
|
url = self.base_url + path
|
|
156
234
|
if query:
|
|
157
235
|
url += "?" + urllib.parse.urlencode(query)
|
|
158
|
-
headers = {
|
|
236
|
+
headers = {**self.auth_headers(), "Accept": "application/json"}
|
|
159
237
|
data = None
|
|
160
238
|
if body is not None:
|
|
161
239
|
data = json.dumps(body).encode()
|
|
@@ -215,6 +293,15 @@ class FigmaClient:
|
|
|
215
293
|
return self.request("DELETE", path)
|
|
216
294
|
|
|
217
295
|
|
|
296
|
+
def checked_me(client: FigmaClient) -> dict[str, Any]:
|
|
297
|
+
"""GET /v1/me, insisting on a user id: proof the credential works."""
|
|
298
|
+
me = client.me()
|
|
299
|
+
if not isinstance(me, dict) or not me.get("id"):
|
|
300
|
+
message = "GET /v1/me returned no user id"
|
|
301
|
+
raise FigmaError({"error": "invalid_response", "message": message})
|
|
302
|
+
return me
|
|
303
|
+
|
|
304
|
+
|
|
218
305
|
def download(url: str, timeout: float = TIMEOUT_SECONDS) -> bytes:
|
|
219
306
|
"""Fetch a pre-signed asset URL. Sends no token, so redirects are safe to follow."""
|
|
220
307
|
if urllib.parse.urlsplit(url).scheme not in ("http", "https"):
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""``figma auth login``: obtain a token, validate it, then store it.
|
|
2
|
+
|
|
3
|
+
The token is checked against ``GET /v1/me`` before anything touches disk, so a
|
|
4
|
+
rejected token never replaces a working one. The OAuth browser flow lives in
|
|
5
|
+
``figma_cli.oauth``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import getpass
|
|
10
|
+
import sys
|
|
11
|
+
import webbrowser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from figma_cli import oauth
|
|
15
|
+
from figma_cli.client import (
|
|
16
|
+
FigmaClient,
|
|
17
|
+
FigmaError,
|
|
18
|
+
UsageError,
|
|
19
|
+
api_base,
|
|
20
|
+
checked_me,
|
|
21
|
+
token_json_path,
|
|
22
|
+
token_path,
|
|
23
|
+
write_private,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SETTINGS_URL = "https://www.figma.com/settings"
|
|
27
|
+
SCOPES = oauth.SCOPES
|
|
28
|
+
INSTRUCTIONS = f"""\
|
|
29
|
+
Create a personal access token for figma-cli:
|
|
30
|
+
1. Open {SETTINGS_URL} and go to Security > Personal access tokens.
|
|
31
|
+
2. Generate a new token with these scopes:
|
|
32
|
+
{chr(10).join(f" {scope}" for scope in SCOPES)}
|
|
33
|
+
3. Paste the token below (input is hidden).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TokenInputError(UsageError):
|
|
38
|
+
"""A missing or malformed token on input: a usage error, exit 2."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _prompt(open_browser: bool) -> str:
|
|
42
|
+
print(INSTRUCTIONS, file=sys.stderr)
|
|
43
|
+
if open_browser:
|
|
44
|
+
webbrowser.open(SETTINGS_URL)
|
|
45
|
+
return getpass.getpass("Figma personal access token: ")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def candidate_token(args: argparse.Namespace) -> str:
|
|
49
|
+
"""Take the token from --token, from stdin, or from a hidden prompt."""
|
|
50
|
+
if args.token is not None and args.token != "-":
|
|
51
|
+
token = args.token
|
|
52
|
+
elif args.token == "-" or not sys.stdin.isatty():
|
|
53
|
+
token = sys.stdin.read()
|
|
54
|
+
else:
|
|
55
|
+
token = _prompt(args.browser is not False)
|
|
56
|
+
token = token.strip()
|
|
57
|
+
if not token:
|
|
58
|
+
raise TokenInputError("empty_token", "no token was provided")
|
|
59
|
+
if any(not "!" <= ch <= "~" for ch in token):
|
|
60
|
+
message = "token must be visible ASCII, without spaces or line breaks"
|
|
61
|
+
raise TokenInputError("invalid_token", message)
|
|
62
|
+
return token
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_token(token: str) -> Path:
|
|
66
|
+
"""Store a personal access token at mode 0600 and drop any OAuth tokens.
|
|
67
|
+
|
|
68
|
+
``token.json`` outranks the plaintext file, so leaving it in place would let
|
|
69
|
+
stale OAuth tokens silently shadow the token just saved.
|
|
70
|
+
"""
|
|
71
|
+
path = token_path()
|
|
72
|
+
write_private(path, token + "\n")
|
|
73
|
+
try:
|
|
74
|
+
token_json_path().unlink(missing_ok=True)
|
|
75
|
+
except OSError as err:
|
|
76
|
+
raise FigmaError({"error": "write_failed", "message": str(err)}) from None
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def login(args: argparse.Namespace) -> tuple[dict, Path]:
|
|
81
|
+
"""Obtain and validate a credential, store it, and return (identity, path).
|
|
82
|
+
|
|
83
|
+
An interactive terminal with OAuth client credentials runs the browser flow;
|
|
84
|
+
everything else (--token, piped stdin, no client credentials) takes a PAT.
|
|
85
|
+
"""
|
|
86
|
+
if args.token is None and sys.stdin.isatty():
|
|
87
|
+
client = oauth.client_credentials(args.client_id, args.client_secret)
|
|
88
|
+
if client:
|
|
89
|
+
return oauth.login(client, args.port, args.browser is not False)
|
|
90
|
+
token = candidate_token(args)
|
|
91
|
+
me = checked_me(FigmaClient(token, api_base()))
|
|
92
|
+
return me, save_token(token)
|