figma-cli 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: figma-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
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,17 +49,39 @@ 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 one under Figma account settings and export it:
52
+ Every command talks to the Figma REST API with a personal access token. Create one 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
+
54
+ | Scope | Used by |
55
+ | --- | --- |
56
+ | `current_user:read` | `auth check`, `auth login` (`GET /v1/me`) |
57
+ | `file_content:read` | `file get`, `export` |
58
+ | `file_comments:read` | `comment list` |
59
+ | `file_comments:write` | `comment post`, `comment delete` |
60
+
61
+ Then store it once with `figma auth login`:
62
+
63
+ ```sh
64
+ figma auth login # interactive: prints the steps, opens settings, hidden prompt
65
+ echo "$TOKEN" | figma auth login # agents and CI: piped stdin
66
+ figma auth login --token - < token.txt # same, explicit
67
+ ```
68
+
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
+
71
+ Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored file whenever it is set and non-empty:
53
72
 
54
73
  ```sh
55
74
  export FIGMA_TOKEN=figd_...
56
75
  export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
57
76
  ```
58
77
 
78
+ With neither set, commands fail with `{"error": "missing_token"}` and exit code 3.
79
+
59
80
  Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
60
81
 
61
82
  | Command | Figma endpoint |
62
83
  | --- | --- |
84
+ | `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
63
85
  | `figma auth check` | `GET /v1/me` |
64
86
  | `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
65
87
  | `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
@@ -161,9 +183,9 @@ ruff format --check .
161
183
  pytest tests/
162
184
  ```
163
185
 
164
- The tests are hermetic: no network access and no Figma token are needed. The redirect tests use real sockets on `127.0.0.1`.
186
+ The tests are hermetic: no network access and no Figma token are needed, and `HOME` points at a temporary directory so a stored token never leaks in. The redirect tests use real sockets on `127.0.0.1`.
165
187
 
166
- `tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` and `FIGMA_TEST_FILE_KEY` are set (add `FIGMA_TEST_NODE_ID` to exercise export). It posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
188
+ `tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` is set. With the token alone it checks `auth check` and runs `auth login` from stdin into a temporary `HOME`; the file, comment and export checks also need `FIGMA_TEST_FILE_KEY` (add `FIGMA_TEST_NODE_ID` to exercise export). The comment check posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
167
189
 
168
190
  ## Release
169
191
 
@@ -24,17 +24,39 @@ 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 one under Figma account settings and export it:
27
+ Every command talks to the Figma REST API with a personal access token. Create one 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
+
29
+ | Scope | Used by |
30
+ | --- | --- |
31
+ | `current_user:read` | `auth check`, `auth login` (`GET /v1/me`) |
32
+ | `file_content:read` | `file get`, `export` |
33
+ | `file_comments:read` | `comment list` |
34
+ | `file_comments:write` | `comment post`, `comment delete` |
35
+
36
+ Then store it once with `figma auth login`:
37
+
38
+ ```sh
39
+ figma auth login # interactive: prints the steps, opens settings, hidden prompt
40
+ echo "$TOKEN" | figma auth login # agents and CI: piped stdin
41
+ figma auth login --token - < token.txt # same, explicit
42
+ ```
43
+
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
+
46
+ Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored file whenever it is set and non-empty:
28
47
 
29
48
  ```sh
30
49
  export FIGMA_TOKEN=figd_...
31
50
  export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
32
51
  ```
33
52
 
53
+ With neither set, commands fail with `{"error": "missing_token"}` and exit code 3.
54
+
34
55
  Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
35
56
 
36
57
  | Command | Figma endpoint |
37
58
  | --- | --- |
59
+ | `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
38
60
  | `figma auth check` | `GET /v1/me` |
39
61
  | `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
40
62
  | `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
@@ -136,9 +158,9 @@ ruff format --check .
136
158
  pytest tests/
137
159
  ```
138
160
 
139
- The tests are hermetic: no network access and no Figma token are needed. The redirect tests use real sockets on `127.0.0.1`.
161
+ The tests are hermetic: no network access and no Figma token are needed, and `HOME` points at a temporary directory so a stored token never leaks in. The redirect tests use real sockets on `127.0.0.1`.
140
162
 
141
- `tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` and `FIGMA_TEST_FILE_KEY` are set (add `FIGMA_TEST_NODE_ID` to exercise export). It posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
163
+ `tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` is set. With the token alone it checks `auth check` and runs `auth login` from stdin into a temporary `HOME`; the file, comment and export checks also need `FIGMA_TEST_FILE_KEY` (add `FIGMA_TEST_NODE_ID` to exercise export). The comment check posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
142
164
 
143
165
  ## Release
144
166
 
@@ -1,3 +1,3 @@
1
1
  """Headless Figma CLI for AI coding agents and automated design inspection."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -13,16 +13,17 @@ from typing import Any
13
13
 
14
14
  from figma_cli import __version__
15
15
  from figma_cli.client import FigmaClient, FigmaError, download
16
+ from figma_cli.login import login
16
17
 
17
- Handler = Callable[[FigmaClient, argparse.Namespace], tuple[Any, str]]
18
+ # Most handlers take (client, args); those marked needs_client=False take (args).
19
+ Handler = Callable[..., tuple[Any, str]]
18
20
 
19
21
 
20
22
  def _print_json(payload: Any) -> None:
21
23
  print(json.dumps(payload, indent=2, ensure_ascii=False))
22
24
 
23
25
 
24
- def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
25
- me = client.me()
26
+ def _identity(me: dict[str, Any]) -> tuple[dict[str, Any], str]:
26
27
  ident = {key: me.get(key) for key in ("id", "handle", "email")}
27
28
  return (
28
29
  ident,
@@ -30,6 +31,16 @@ def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str
30
31
  )
31
32
 
32
33
 
34
+ def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
35
+ return _identity(client.me())
36
+
37
+
38
+ def _auth_login(args: argparse.Namespace) -> tuple[Any, str]:
39
+ me, path = login(args)
40
+ ident, text = _identity(me)
41
+ return {**ident, "token_path": str(path)}, f"{text}\nToken saved to {path}"
42
+
43
+
33
44
  def _tree_lines(node: dict[str, Any], indent: int = 0) -> list[str]:
34
45
  label = f"{node.get('type', '?')} {node.get('name', '')} ({node.get('id', '')})"
35
46
  lines = [" " * indent + label]
@@ -125,7 +136,10 @@ def build_parser() -> argparse.ArgumentParser:
125
136
  description=(
126
137
  "Headless Figma CLI for AI coding agents and automated design inspection."
127
138
  ),
128
- epilog="Environment: FIGMA_TOKEN (required), FIGMA_API_BASE (optional).",
139
+ epilog=(
140
+ "Environment: FIGMA_TOKEN (optional when 'figma auth login' has stored"
141
+ " ~/.config/figma/token), FIGMA_API_BASE (optional)."
142
+ ),
129
143
  )
130
144
  parser.add_argument(
131
145
  "--version", action="version", version=f"%(prog)s {__version__}"
@@ -134,10 +148,30 @@ def build_parser() -> argparse.ArgumentParser:
134
148
  common.add_argument("--json", action="store_true", help="print structured JSON")
135
149
  commands = parser.add_subparsers(dest="command", metavar="<command>")
136
150
 
137
- auth = commands.add_parser("auth", help="token checks")
151
+ auth = commands.add_parser("auth", help="token setup and checks")
138
152
  auth_cmds = auth.add_subparsers(dest="action", metavar="<action>", required=True)
139
- p = auth_cmds.add_parser("check", parents=[common], help="validate FIGMA_TOKEN")
153
+ p = auth_cmds.add_parser(
154
+ "check", parents=[common], help="validate FIGMA_TOKEN or stored token file"
155
+ )
140
156
  p.set_defaults(handler=_auth_check)
157
+ p = auth_cmds.add_parser(
158
+ "login",
159
+ parents=[common],
160
+ help="validate a token and store it in ~/.config/figma/token",
161
+ )
162
+ p.add_argument(
163
+ "--token",
164
+ help=(
165
+ "token value, or '-' to read stdin; a literal value shows in ps and"
166
+ " shell history, so prefer piped stdin, which is read without this flag"
167
+ ),
168
+ )
169
+ p.add_argument(
170
+ "--browser",
171
+ action=argparse.BooleanOptionalAction,
172
+ help="open Figma settings when prompting interactively (default: open)",
173
+ )
174
+ p.set_defaults(handler=_auth_login, needs_client=False)
141
175
 
142
176
  file = commands.add_parser("file", help="file inspection")
143
177
  file_cmds = file.add_subparsers(dest="action", metavar="<action>", required=True)
@@ -194,7 +228,10 @@ def main(argv: Sequence[str] | None = None) -> int:
194
228
  parser.print_help()
195
229
  return 0
196
230
  try:
197
- payload, text = handler(FigmaClient.from_env(), args)
231
+ if getattr(args, "needs_client", True):
232
+ payload, text = handler(FigmaClient.from_env(), args)
233
+ else:
234
+ payload, text = handler(args)
198
235
  except FigmaError as err:
199
236
  if args.json:
200
237
  _print_json(err.payload)
@@ -14,11 +14,13 @@ import time
14
14
  import urllib.error
15
15
  import urllib.parse
16
16
  import urllib.request
17
+ from pathlib import Path
17
18
  from typing import Any
18
19
 
19
20
  DEFAULT_API_BASE = "https://api.figma.com"
20
21
  TOKEN_HEADER = "X-Figma-Token"
21
22
  TIMEOUT_SECONDS = 60
23
+ EXIT_USAGE = 2
22
24
  EXIT_API_ERROR = 3
23
25
 
24
26
  _ERROR_NAMES = {
@@ -93,6 +95,26 @@ def _network_error(err: OSError) -> FigmaError:
93
95
  return FigmaError({"error": "network_error", "message": str(reason)})
94
96
 
95
97
 
98
+ def token_path() -> Path:
99
+ """Where ``figma auth login`` stores the token: ``~/.config/figma/token``."""
100
+ return Path.home() / ".config" / "figma" / "token"
101
+
102
+
103
+ def read_token_file() -> str:
104
+ """Return the stored token, or "" when no regular file holds one."""
105
+ path = token_path()
106
+ if not path.is_file():
107
+ return ""
108
+ try:
109
+ return path.read_text(encoding="utf-8").strip()
110
+ except (OSError, UnicodeError) as err:
111
+ raise FigmaError({"error": "token_unreadable", "message": str(err)}) from None
112
+
113
+
114
+ def api_base() -> str:
115
+ return os.environ.get("FIGMA_API_BASE", "").strip() or DEFAULT_API_BASE
116
+
117
+
96
118
  def _quote(segment: str) -> str:
97
119
  return urllib.parse.quote(segment, safe="")
98
120
 
@@ -113,13 +135,15 @@ class FigmaClient:
113
135
 
114
136
  @classmethod
115
137
  def from_env(cls) -> "FigmaClient":
116
- token = os.environ.get("FIGMA_TOKEN", "").strip()
138
+ """Read FIGMA_TOKEN, falling back to the stored token file."""
139
+ token = os.environ.get("FIGMA_TOKEN", "").strip() or read_token_file()
117
140
  if not token:
118
- raise FigmaError(
119
- {"error": "missing_token", "message": "FIGMA_TOKEN is not set"}
141
+ message = (
142
+ f"FIGMA_TOKEN is not set and {token_path()} holds no token;"
143
+ " run 'figma auth login'"
120
144
  )
121
- base = os.environ.get("FIGMA_API_BASE", "").strip() or DEFAULT_API_BASE
122
- return cls(token, base)
145
+ raise FigmaError({"error": "missing_token", "message": message})
146
+ return cls(token, api_base())
123
147
 
124
148
  def request(
125
149
  self,
@@ -0,0 +1,108 @@
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.
5
+ """
6
+
7
+ import argparse
8
+ import getpass
9
+ import os
10
+ import secrets
11
+ import sys
12
+ import webbrowser
13
+ from pathlib import Path
14
+
15
+ from figma_cli.client import EXIT_USAGE, FigmaClient, FigmaError, api_base, token_path
16
+
17
+ SETTINGS_URL = "https://www.figma.com/settings"
18
+ SCOPES = (
19
+ "current_user:read",
20
+ "file_content:read",
21
+ "file_comments:read",
22
+ "file_comments:write",
23
+ )
24
+ INSTRUCTIONS = f"""\
25
+ Create a personal access token for figma-cli:
26
+ 1. Open {SETTINGS_URL} and go to Security > Personal access tokens.
27
+ 2. Generate a new token with these scopes:
28
+ {chr(10).join(f" {scope}" for scope in SCOPES)}
29
+ 3. Paste the token below (input is hidden).
30
+ """
31
+
32
+
33
+ class TokenInputError(FigmaError):
34
+ """A missing or malformed token on input: a usage error, exit 2."""
35
+
36
+ exit_code = EXIT_USAGE
37
+
38
+ def __init__(self, error: str, message: str):
39
+ super().__init__({"error": error, "message": message})
40
+
41
+
42
+ def _prompt(open_browser: bool) -> str:
43
+ print(INSTRUCTIONS, file=sys.stderr)
44
+ if open_browser:
45
+ webbrowser.open(SETTINGS_URL)
46
+ return getpass.getpass("Figma personal access token: ")
47
+
48
+
49
+ def candidate_token(args: argparse.Namespace) -> str:
50
+ """Take the token from --token, from stdin, or from a hidden prompt."""
51
+ if args.token is not None and args.token != "-":
52
+ token = args.token
53
+ elif args.token == "-" or not sys.stdin.isatty():
54
+ token = sys.stdin.read()
55
+ else:
56
+ token = _prompt(args.browser is not False)
57
+ token = token.strip()
58
+ if not token:
59
+ raise TokenInputError("empty_token", "no token was provided")
60
+ if any(not "!" <= ch <= "~" for ch in token):
61
+ message = "token must be visible ASCII, without spaces or line breaks"
62
+ raise TokenInputError("invalid_token", message)
63
+ return token
64
+
65
+
66
+ def save_token(token: str) -> Path:
67
+ """Store the token at mode 0600, replacing any old file only once it is written.
68
+
69
+ The new file is created 0600 under a fresh name (O_EXCL, so never through a
70
+ planted file or symlink), then renamed over the old one: a failed write
71
+ leaves the previous token intact, and nothing is ever readable by others.
72
+ """
73
+ path = token_path()
74
+ tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}")
75
+ try:
76
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
77
+ path.parent.chmod(0o700) # refuses a directory owned by someone else
78
+ fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
79
+ try:
80
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
81
+ fh.write(token + "\n")
82
+ fh.flush()
83
+ os.fsync(fh.fileno())
84
+ os.replace(tmp, path)
85
+ try:
86
+ dir_fd = os.open(path.parent, os.O_RDONLY)
87
+ try:
88
+ os.fsync(dir_fd)
89
+ finally:
90
+ os.close(dir_fd)
91
+ except OSError:
92
+ pass
93
+ except BaseException:
94
+ tmp.unlink(missing_ok=True)
95
+ raise
96
+ except OSError as err:
97
+ raise FigmaError({"error": "write_failed", "message": str(err)}) from None
98
+ return path
99
+
100
+
101
+ def login(args: argparse.Namespace) -> tuple[dict, Path]:
102
+ """Validate a candidate token, store it, and return (identity, path)."""
103
+ token = candidate_token(args)
104
+ me = FigmaClient(token, api_base()).me()
105
+ if not isinstance(me, dict) or not me.get("id"):
106
+ message = "GET /v1/me returned no user id"
107
+ raise FigmaError({"error": "invalid_response", "message": message})
108
+ return me, save_token(token)
@@ -0,0 +1,11 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.fixture(autouse=True)
5
+ def home(monkeypatch, tmp_path):
6
+ """Point HOME at an empty directory so no test reads a real stored token."""
7
+ home = tmp_path / "home"
8
+ home.mkdir()
9
+ monkeypatch.setenv("HOME", str(home))
10
+ monkeypatch.setenv("USERPROFILE", str(home))
11
+ return home
@@ -1,5 +1,6 @@
1
1
  """Hermetic tests for the CLI entrypoint: no network, no installed scripts required."""
2
2
 
3
+ import io
3
4
  import json
4
5
  import os
5
6
  import subprocess
@@ -242,7 +243,116 @@ def test_error_human_on_stderr(stub, monkeypatch, capsys):
242
243
  assert "forbidden (HTTP 403): Invalid token" in captured.err
243
244
 
244
245
 
245
- def test_missing_token_exits_three(monkeypatch, capsys):
246
+ def test_missing_token_exits_three(home, monkeypatch, capsys):
247
+ monkeypatch.setenv("HOME", str(home))
246
248
  monkeypatch.delenv("FIGMA_TOKEN", raising=False)
247
249
  assert main(["auth", "check", "--json"]) == 3
248
250
  assert json.loads(capsys.readouterr().out)["error"] == "missing_token"
251
+
252
+
253
+ # auth login: token acquisition, with a stub standing in for GET /v1/me.
254
+
255
+
256
+ class FakeStdin(io.StringIO):
257
+ def __init__(self, text: str = "", tty: bool = False):
258
+ super().__init__(text)
259
+ self.tty = tty
260
+
261
+ def isatty(self):
262
+ return self.tty
263
+
264
+
265
+ @pytest.fixture
266
+ def login_stub(monkeypatch):
267
+ """Record the token login validates with; fail if it goes through from_env."""
268
+ seen = []
269
+
270
+ def build(token, base):
271
+ seen.append((token, base))
272
+ return StubClient()
273
+
274
+ def no_env():
275
+ raise AssertionError("auth login must not call FigmaClient.from_env")
276
+
277
+ monkeypatch.setattr("figma_cli.login.FigmaClient", build)
278
+ monkeypatch.setattr("figma_cli.cli.FigmaClient.from_env", no_env)
279
+ monkeypatch.delenv("FIGMA_TOKEN", raising=False)
280
+ monkeypatch.delenv("FIGMA_API_BASE", raising=False)
281
+ return seen
282
+
283
+
284
+ def _stored(home: Path) -> str:
285
+ return (home / ".config" / "figma" / "token").read_text()
286
+
287
+
288
+ def test_login_token_flag(login_stub, home, monkeypatch, capsys):
289
+ monkeypatch.setattr(sys, "stdin", FakeStdin("ignored"))
290
+ assert main(["auth", "login", "--token", " figd_flag ", "--json"]) == 0
291
+ assert login_stub == [("figd_flag", "https://api.figma.com")]
292
+ assert _stored(home) == "figd_flag\n"
293
+ out = json.loads(capsys.readouterr().out)
294
+ assert out == {
295
+ "id": "1",
296
+ "handle": "agent",
297
+ "email": "a@example.com",
298
+ "token_path": str(home / ".config" / "figma" / "token"),
299
+ }
300
+
301
+
302
+ @pytest.mark.parametrize("argv", [["--token", "-"], []])
303
+ def test_login_reads_stdin(argv, login_stub, home, monkeypatch, capsys):
304
+ monkeypatch.setattr(sys, "stdin", FakeStdin("figd_piped\n"))
305
+ assert main(["auth", "login", *argv]) == 0
306
+ assert login_stub[0][0] == "figd_piped"
307
+ assert _stored(home) == "figd_piped\n"
308
+ out = capsys.readouterr().out
309
+ assert "Authenticated as agent <a@example.com> (1)" in out
310
+ assert "Token saved to" in out
311
+
312
+
313
+ @pytest.mark.parametrize("argv", [["--token", "-"], [], ["--token", " "]])
314
+ def test_login_empty_token_exits_two(argv, login_stub, home, monkeypatch, capsys):
315
+ monkeypatch.setattr(sys, "stdin", FakeStdin(" \n"))
316
+ assert main(["auth", "login", *argv, "--json"]) == 2
317
+ assert json.loads(capsys.readouterr().out)["error"] == "empty_token"
318
+ assert login_stub == []
319
+ assert not (home / ".config").exists()
320
+
321
+
322
+ @pytest.mark.parametrize("flag, opened", [([], True), (["--no-browser"], False)])
323
+ def test_login_interactive_prompt(flag, opened, login_stub, home, monkeypatch, capsys):
324
+ urls = []
325
+ monkeypatch.setattr(sys, "stdin", FakeStdin(tty=True))
326
+ monkeypatch.setattr("getpass.getpass", lambda prompt: "figd_typed")
327
+ monkeypatch.setattr("webbrowser.open", urls.append)
328
+ assert main(["auth", "login", *flag]) == 0
329
+ assert urls == (["https://www.figma.com/settings"] if opened else [])
330
+ err = capsys.readouterr().err
331
+ for scope in (
332
+ "current_user:read",
333
+ "file_content:read",
334
+ "file_comments:read",
335
+ "file_comments:write",
336
+ ):
337
+ assert scope in err
338
+ assert _stored(home) == "figd_typed\n"
339
+
340
+
341
+ def test_login_rejected_token_writes_nothing(login_stub, home, monkeypatch, capsys):
342
+ payload = {"error": "forbidden", "status": 403, "message": "Invalid token"}
343
+ monkeypatch.setattr(StubClient, "me", _raise(payload))
344
+ monkeypatch.setattr(sys, "stdin", FakeStdin("figd_bad"))
345
+ assert main(["auth", "login", "--json"]) == 3
346
+ assert json.loads(capsys.readouterr().out) == payload
347
+ assert not (home / ".config").exists()
348
+
349
+
350
+ @pytest.mark.parametrize(
351
+ "token", ["figd_a\nfigd_b", "figd_a b", "figd_\x00", "figd_\u20ac"]
352
+ )
353
+ def test_login_malformed_token_exits_two(token, login_stub, home, monkeypatch, capsys):
354
+ monkeypatch.setattr(sys, "stdin", FakeStdin(token))
355
+ assert main(["auth", "login", "--json"]) == 2
356
+ assert json.loads(capsys.readouterr().out)["error"] == "invalid_token"
357
+ assert login_stub == []
358
+ assert not (home / ".config").exists()
@@ -4,6 +4,9 @@ import email.message
4
4
  import email.utils
5
5
  import io
6
6
  import json
7
+ import os
8
+ import stat
9
+ import sys
7
10
  import threading
8
11
  import urllib.error
9
12
  from collections.abc import Iterator
@@ -134,7 +137,8 @@ def test_delete_comment_accepts_empty_body():
134
137
  assert opener.requests[0].full_url.endswith("/v1/files/k/comments/42")
135
138
 
136
139
 
137
- def test_missing_token(monkeypatch):
140
+ def test_missing_token(home, monkeypatch):
141
+ monkeypatch.setenv("HOME", str(home))
138
142
  monkeypatch.delenv("FIGMA_TOKEN", raising=False)
139
143
  with pytest.raises(FigmaError) as exc:
140
144
  FigmaClient.from_env()
@@ -241,3 +245,160 @@ def test_export_downloads_asset_without_token(monkeypatch, tmp_path, capsys):
241
245
  assert [r[1] for r in cdn_rec.requests] == ["/hop/1-2.png", "/1-2.png"]
242
246
  assert not any(_has_token(r[2]) for r in cdn_rec.requests)
243
247
  assert not any(TOKEN in json.dumps(r) for r in cdn_rec.requests)
248
+
249
+
250
+ # Stored token: auth login against a loopback /v1/me, and the from_env fallback.
251
+
252
+ ME = {"id": "123", "handle": "test", "email": "test@example.com"}
253
+
254
+
255
+ def _token_file(home):
256
+ return home / ".config" / "figma" / "token"
257
+
258
+
259
+ def _seed(home, content: bytes, mode: int = 0o600):
260
+ path = _token_file(home)
261
+ path.parent.mkdir(parents=True)
262
+ path.write_bytes(content)
263
+ path.chmod(mode)
264
+ return path
265
+
266
+
267
+ def _login(monkeypatch, api: str, token: str, *argv: str) -> int:
268
+ monkeypatch.setenv("FIGMA_API_BASE", api)
269
+ monkeypatch.delenv("FIGMA_TOKEN", raising=False)
270
+ monkeypatch.setattr(sys, "stdin", io.StringIO(token + "\n"))
271
+ return main(["auth", "login", "--json", *argv])
272
+
273
+
274
+ def test_login_first_run_stores_token_0600(home, monkeypatch, capsys):
275
+ with serve() as (api, rec):
276
+ rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
277
+ assert _login(monkeypatch, api, TOKEN) == 0
278
+ assert rec.requests[0][2].get(TOKEN_HEADER) == TOKEN
279
+ path = _token_file(home)
280
+ assert json.loads(capsys.readouterr().out) == {**ME, "token_path": str(path)}
281
+ assert path.read_text() == TOKEN + "\n"
282
+ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
283
+ assert stat.S_IMODE(os.stat(path.parent).st_mode) == 0o700
284
+
285
+
286
+ def test_login_tightens_looser_existing_file(home, monkeypatch, capsys):
287
+ path = _seed(home, b"figd_old\n", mode=0o644)
288
+ with serve() as (api, rec):
289
+ rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
290
+ assert _login(monkeypatch, api, TOKEN) == 0
291
+ assert path.read_text() == TOKEN + "\n"
292
+ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
293
+
294
+
295
+ @pytest.mark.parametrize("status", [401, 403])
296
+ def test_login_rejected_creates_no_file(status, home, monkeypatch, capsys):
297
+ with serve() as (api, rec):
298
+ rec.routes["/v1/me"] = (status, {}, b'{"err": "Invalid token"}')
299
+ assert _login(monkeypatch, api, "figd_bad") == 3
300
+ out = json.loads(capsys.readouterr().out)
301
+ assert out["status"] == status
302
+ assert not (home / ".config").exists()
303
+
304
+
305
+ @pytest.mark.parametrize("status", [401, 403])
306
+ def test_login_rejected_preserves_existing_file(status, home, monkeypatch, capsys):
307
+ original = b"figd_working-token\n"
308
+ path = _seed(home, original)
309
+ with serve() as (api, rec):
310
+ rec.routes["/v1/me"] = (status, {}, b"{}")
311
+ assert _login(monkeypatch, api, "figd_bad") == 3
312
+ assert path.read_bytes() == original
313
+ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
314
+
315
+
316
+ def test_login_write_failure_is_structured(home, monkeypatch, capsys):
317
+ (home / ".config").write_text("not a directory")
318
+ with serve() as (api, rec):
319
+ rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
320
+ assert _login(monkeypatch, api, TOKEN) == 3
321
+ assert json.loads(capsys.readouterr().out)["error"] == "write_failed"
322
+
323
+
324
+ def test_check_falls_back_to_stripped_token_file(home, monkeypatch, capsys):
325
+ _seed(home, f" {TOKEN}\n\n".encode())
326
+ monkeypatch.delenv("FIGMA_TOKEN", raising=False)
327
+ with serve() as (api, rec):
328
+ rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
329
+ monkeypatch.setenv("FIGMA_API_BASE", api)
330
+ assert main(["auth", "check", "--json"]) == 0
331
+ assert rec.requests[0][2].get(TOKEN_HEADER) == TOKEN
332
+ assert json.loads(capsys.readouterr().out) == ME
333
+
334
+
335
+ def test_env_token_overrides_token_file(home, monkeypatch):
336
+ _seed(home, b"figd_from_file\n")
337
+ monkeypatch.setenv("FIGMA_TOKEN", TOKEN)
338
+ assert FigmaClient.from_env().token == TOKEN
339
+ monkeypatch.setenv("FIGMA_TOKEN", " ")
340
+ assert FigmaClient.from_env().token == "figd_from_file"
341
+
342
+
343
+ def test_token_file_directory_counts_as_missing(home, monkeypatch):
344
+ _token_file(home).mkdir(parents=True)
345
+ monkeypatch.delenv("FIGMA_TOKEN", raising=False)
346
+ with pytest.raises(FigmaError) as exc:
347
+ FigmaClient.from_env()
348
+ assert exc.value.payload["error"] == "missing_token"
349
+
350
+
351
+ def test_undecodable_token_file_is_structured(home, monkeypatch, capsys):
352
+ _seed(home, b"\xff\xfe\x00bad")
353
+ monkeypatch.delenv("FIGMA_TOKEN", raising=False)
354
+ assert main(["auth", "check", "--json"]) == 3
355
+ assert json.loads(capsys.readouterr().out)["error"] == "token_unreadable"
356
+
357
+
358
+ def test_login_replaces_symlink_without_following_it(home, monkeypatch, capsys):
359
+ target = home / "elsewhere"
360
+ target.write_text("untouched\n")
361
+ path = _token_file(home)
362
+ path.parent.mkdir(parents=True)
363
+ path.symlink_to(target)
364
+ with serve() as (api, rec):
365
+ rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
366
+ assert _login(monkeypatch, api, TOKEN) == 0
367
+ assert target.read_text() == "untouched\n"
368
+ assert not path.is_symlink()
369
+ assert path.read_text() == TOKEN + "\n"
370
+
371
+
372
+ def test_login_tightens_existing_directory(home, monkeypatch, capsys):
373
+ _token_file(home).parent.mkdir(parents=True, mode=0o755)
374
+ with serve() as (api, rec):
375
+ rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
376
+ assert _login(monkeypatch, api, TOKEN) == 0
377
+ assert stat.S_IMODE(os.stat(_token_file(home).parent).st_mode) == 0o700
378
+
379
+
380
+ def test_login_failed_write_keeps_old_token(home, monkeypatch, capsys):
381
+ original = b"figd_working-token\n"
382
+ path = _seed(home, original)
383
+
384
+ def fail(*args):
385
+ raise OSError("disk full")
386
+
387
+ monkeypatch.setattr("figma_cli.login.os.replace", fail)
388
+ with serve() as (api, rec):
389
+ rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
390
+ assert _login(monkeypatch, api, TOKEN) == 3
391
+ assert json.loads(capsys.readouterr().out)["error"] == "write_failed"
392
+ assert path.read_bytes() == original
393
+ assert sorted(p.name for p in path.parent.iterdir()) == ["token"]
394
+
395
+
396
+ @pytest.mark.parametrize("body", [b"{}", b"[]", b'{"handle": "x"}'])
397
+ def test_login_without_identity_keeps_old_token(body, home, monkeypatch, capsys):
398
+ original = b"figd_working-token\n"
399
+ path = _seed(home, original)
400
+ with serve() as (api, rec):
401
+ rec.routes["/v1/me"] = (200, {}, body)
402
+ assert _login(monkeypatch, api, TOKEN) == 3
403
+ assert json.loads(capsys.readouterr().out)["error"] == "invalid_response"
404
+ assert path.read_bytes() == original
@@ -1,11 +1,15 @@
1
- """Live Figma API checks. Skipped unless FIGMA_TOKEN and FIGMA_TEST_FILE_KEY are set.
1
+ """Live Figma API checks. Skipped unless FIGMA_TOKEN is set; the file, export and
2
+ comment checks also need FIGMA_TEST_FILE_KEY.
2
3
 
3
4
  FIGMA_TEST_NODE_ID (optional) names a frame to export. The comment test posts one
4
5
  comment and deletes it again.
5
6
  """
6
7
 
8
+ import io
7
9
  import json
8
10
  import os
11
+ import stat
12
+ import sys
9
13
  import time
10
14
 
11
15
  import pytest
@@ -16,9 +20,8 @@ TOKEN = os.environ.get("FIGMA_TOKEN", "").strip()
16
20
  FILE_KEY = os.environ.get("FIGMA_TEST_FILE_KEY", "").strip()
17
21
  NODE_ID = os.environ.get("FIGMA_TEST_NODE_ID", "").strip()
18
22
 
19
- pytestmark = pytest.mark.skipif(
20
- not (TOKEN and FILE_KEY), reason="FIGMA_TOKEN and FIGMA_TEST_FILE_KEY not set"
21
- )
23
+ pytestmark = pytest.mark.skipif(not TOKEN, reason="FIGMA_TOKEN not set")
24
+ needs_file = pytest.mark.skipif(not FILE_KEY, reason="FIGMA_TEST_FILE_KEY not set")
22
25
 
23
26
 
24
27
  def _run(capsys, *argv):
@@ -41,6 +44,20 @@ def test_bogus_token_is_structured(monkeypatch, capsys):
41
44
  assert out["status"] in (401, 403)
42
45
 
43
46
 
47
+ def test_login_then_check_from_token_file(home, monkeypatch, capsys):
48
+ monkeypatch.delenv("FIGMA_TOKEN")
49
+ monkeypatch.setattr(sys, "stdin", io.StringIO(TOKEN + "\n"))
50
+ code, out = _run(capsys, "auth", "login")
51
+ assert code == 0, out
52
+ path = home / ".config" / "figma" / "token"
53
+ assert out["token_path"] == str(path)
54
+ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
55
+ code, checked = _run(capsys, "auth", "check")
56
+ assert code == 0, checked
57
+ assert checked == {key: out[key] for key in ("id", "handle", "email")}
58
+
59
+
60
+ @needs_file
44
61
  def test_file_get_depth_one(capsys):
45
62
  code, out = _run(capsys, "file", "get", FILE_KEY, "--depth", "1")
46
63
  assert code == 0, out
@@ -49,6 +66,7 @@ def test_file_get_depth_one(capsys):
49
66
  assert all("children" not in page for page in pages)
50
67
 
51
68
 
69
+ @needs_file
52
70
  @pytest.mark.skipif(not NODE_ID, reason="FIGMA_TEST_NODE_ID not set")
53
71
  def test_export_png(tmp_path, capsys):
54
72
  argv = ("export", FILE_KEY, "--nodes", NODE_ID, "--output", str(tmp_path))
@@ -58,6 +76,7 @@ def test_export_png(tmp_path, capsys):
58
76
  assert fh.read(4) == b"\x89PNG"
59
77
 
60
78
 
79
+ @needs_file
61
80
  def test_comment_round_trip(capsys):
62
81
  code, posted = _run(capsys, "comment", "post", FILE_KEY, "--message", "test")
63
82
  assert code == 0, posted
File without changes
File without changes
File without changes
File without changes