omp-figma-remote-auth 0.1.1 → 0.1.2

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.
package/.mcp.json ADDED
@@ -0,0 +1 @@
1
+ {"mcpServers":{"figma":{"type":"http","url":"https://mcp.figma.com/mcp"}}}
package/README.md CHANGED
@@ -7,19 +7,26 @@ An unofficial [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi) plugin for a
7
7
  ## Requirements
8
8
 
9
9
  - OMP **18.1.17 is the currently verified version**. Use 18.1.17+; compatibility with future releases is not guaranteed.
10
- - Git, a Figma account with access to the intended files, and a browser on the same machine as OMP for the local OAuth callback. Figma Desktop is not required.
10
+ - A Figma account with access to the intended files, and a browser on the same machine as OMP for the local OAuth callback. Figma Desktop is not required. Git is only needed for source installation.
11
11
  - Zero runtime package dependencies. No `pi-mcp-adapter`, `npm install`, or build step is needed. Development tests require Node.js 22.6.0+ and npm.
12
+ - OMP's npm installer requires the standalone `bun` command on `PATH`. A packaged OMP binary may not include it; see the [Bun installation guide](https://bun.com/docs/installation). This is an installer requirement, not a package dependency.
12
13
 
13
14
  ## Install and connect
14
15
 
15
16
  Run this command in your **terminal (CLI)** to install from npm:
16
17
 
17
18
  ```sh
18
- omp install omp-figma-remote-auth
19
+ omp plugin install omp-figma-remote-auth@latest
19
20
  ```
20
21
 
21
22
  Restart OMP, or enter `/reload-plugins` in an existing OMP session. No source checkout or separate `npm install -g` is required.
22
23
 
24
+ If OMP reports `Executable not found in $PATH: "bun"`, install Bun first. With Node.js/npm available, you can also supply Bun temporarily for this command:
25
+
26
+ ```sh
27
+ npm exec --yes --package=bun -- omp plugin install omp-figma-remote-auth@latest
28
+ ```
29
+
23
30
  To develop the plugin or install from source instead:
24
31
 
25
32
  ```sh
@@ -29,6 +36,18 @@ omp plugin link ./omp-figma-remote-auth
29
36
 
30
37
  For a source installation, keep the cloned directory in place: OMP links to it. Restart OMP or run `/reload-plugins` after linking.
31
38
 
39
+ Install or link the full package to use its MCP registration. Loading only `index.ts` with `omp -e` is useful for testing the authentication commands, but is not a substitute for package installation. A successful `/figma-remote-auth status` confirms local credential state; it does not confirm that OMP discovered or connected the MCP server. For normal use, start `omp` after installing the package, then run `/mcp reload` and `/mcp test figma`.
40
+
41
+ To switch an existing development link to the registry package:
42
+
43
+ 1. Record the source checkout's canonical path with `realpath /absolute/path/to/omp-figma-remote-auth`, and note the installed plugin path in OMP's plugins directory (normally `~/.omp/plugins`).
44
+ 2. Run `omp plugin uninstall omp-figma-remote-auth`, then `omp plugin list`. Confirm that the list no longer contains `omp-figma-remote-auth`; stop if it still does.
45
+ 3. If the installed plugin path remains, inspect it before proceeding. Only if it is proven to be a leftover symlink whose `realpath` equals the recorded source checkout path may you use `unlink /absolute/path/to/installed/plugin` to remove that symlink alone. Never recursively delete the target. Stop and investigate any other directory or link conflict, including a link whose destination cannot be verified.
46
+ 4. Install with `omp plugin install omp-figma-remote-auth@latest`, or `npm exec --yes --package=bun -- omp plugin install omp-figma-remote-auth@latest` if Bun is not on PATH.
47
+ 5. Run `omp plugin list` to confirm installation, then run `realpath /absolute/path/to/installed/plugin`. The resolved path must not equal the recorded source checkout path; stop if it does or cannot be resolved. A registry installation may legitimately use a symlink into a package cache; that alone is not an error and is not a reason to unlink it.
48
+
49
+ Replace the example absolute paths with your actual paths. Restart OMP or run `/reload-plugins` after migration.
50
+
32
51
  The following are **slash commands inside OMP's interactive interface (TUI)**, not shell commands. Start OMP with `omp` if needed, then run:
33
52
 
34
53
  ```text
@@ -36,9 +55,13 @@ The following are **slash commands inside OMP's interactive interface (TUI)**, n
36
55
  /figma-remote-auth login
37
56
  ```
38
57
 
39
- `setup` configures the server without logging in. It is optional here because `login` runs setup automatically.
58
+ The package's `.mcp.json` provides the `figma` HTTP MCP server. A new installation writes no user `mcp.json`. `setup` checks for conflicts and migrates this plugin's exact legacy registration and owned credentials without logging in; `login` runs setup automatically.
59
+
60
+ When upgrading from the legacy version, run `/figma-remote-auth setup` in each affected profile, then `/mcp reload` or restart OMP. Setup removes only the exact legacy `mcpServers.figma` entry from that profile's agent-directory `mcp.json` and migrates owned credentials to OMP's native profile/URL credential key. OMP may attempt its first MCP connection before setup runs, so the initial connection can still use the old registration or lack the migrated credentials. Reload or restart after migration to pick up the result.
40
61
 
41
- Open the displayed authorization URL manually in a browser on the same machine. With the default settings, Figma's consent page shows **Codex** as the application name; see the authentication explanation below. Approve access, keep OMP running for the callback to `http://127.0.0.1:<port>/callback`, and return to OMP to confirm that authorization was saved. The browser receiving the code alone does not confirm that token exchange succeeded.
62
+ In the interactive TUI, login opens your system browser automatically and keeps a bold **点击这里授权 Figma** (“Click here to authorize Figma”) link above the editor. If the browser does not open, click that link or copy its complete local URL into a browser on the same machine. The short `http://127.0.0.1:<port>/a/<random-token>` entry redirects to Figma with all OAuth parameters intact; the long authorization URL is never printed in the terminal. When terminal hyperlinks are unsupported or disabled, the complete short URL remains available to copy.
63
+
64
+ With the default settings, Figma's consent page shows **Codex** as the application name; see the authentication explanation below. Approve access, keep OMP running for the callback to `http://127.0.0.1:<port>/callback`, and return to OMP to confirm that authorization was saved. Opening the browser or receiving the code alone does not confirm that token exchange succeeded. The entry remains available while waiting and is removed on success, failure, timeout, cancellation, or OMP shutdown.
42
65
 
43
66
  After OMP confirms success, run these TUI commands:
44
67
 
@@ -56,43 +79,46 @@ All commands in this table run in the OMP TUI.
56
79
  | Command | Purpose |
57
80
  | --- | --- |
58
81
  | `/figma-remote-auth help` | Show usage; also the default with no subcommand. |
59
- | `/figma-remote-auth setup` | Add or merge the native `figma` HTTP MCP configuration. |
60
- | `/figma-remote-auth login` | Run setup, dynamically register a client, and display the browser authorization link. |
61
- | `/figma-remote-auth status` | Check local configuration, credential presence/expiry, and any active operation; this is not a live connection test. |
62
- | `/figma-remote-auth logout` | Delete only this plugin's credential for the active profile; keep the MCP configuration. |
82
+ | `/figma-remote-auth setup` | Check conflicts and migrate the exact owned legacy registration/credentials to package-provided MCP and native credentials; create no user MCP configuration. |
83
+ | `/figma-remote-auth login` | Run setup, dynamically register a client, open the browser, and display a highlighted local authorization entry. |
84
+ | `/figma-remote-auth status` | Report package-provided MCP, legacy registration migration/conflict state, native credential presence/expiry, and any active operation; this is not a live connection test. |
85
+ | `/figma-remote-auth logout` | Delete only locally owned native and legacy credentials for the active profile/agent directory; keep registration and do not revoke Figma authorization. |
63
86
  | `/figma-remote-auth cancel` | Cancel the pending authorization and close its callback listener. |
64
87
 
65
- `login` accepts `--client-name` (default `Codex`, 1–128 printable characters, not blank) and `--port` (default `0`, which lets the OS choose an available port; valid range 0–65535). For example:
88
+ `login` accepts `--client-name` (default `Codex`, 1–128 printable characters, not blank), `--port` (default `0`, which lets the OS choose an available port; valid range 0–65535), and the value-free `--no-browser` flag to open the short entry manually. Flags can be mixed in any order; duplicates are rejected. RPC and print modes only provide the short URL and never automatically open a browser. For example:
66
89
 
67
90
  ```text
68
91
  /figma-remote-auth login --client-name Codex --port 19876
92
+ /figma-remote-auth login --no-browser
69
93
  ```
70
94
 
71
95
  Quote names containing spaces. A different client name may be rejected by Figma. Login times out after 10 minutes; `cancel` or exiting OMP also stops the wait. Run `/mcp reload` after setup, login, or logout so OMP picks up the change.
72
96
 
97
+ For SSH or container sessions, `--no-browser` avoids opening a browser on the remote host. The callback and short entry still require access to that host's loopback interface; the plugin does not set up public proxies or port forwarding. On an unsupported OMP version without the widget API, it reports the limitation and provides the complete short URL as plain text.
98
+
73
99
  ## Authentication, profiles, and limits
74
100
 
75
101
  Each login sends `client_name: Codex` by default to Figma's dynamic client registration endpoint and receives a **new `client_id`**. It then uses the OAuth authorization code flow with PKCE S256 and state validation. It does not reuse or steal an existing Codex client ID, secret, or account token. The display name does not make this an official Codex integration. This is a compatibility workaround for Figma's client acceptance policy; Figma may change registration rules or endpoints and stop accepting it. See Figma's [client access policy](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/#which-mcp-clients-are-supported).
76
102
 
77
- Setup writes to the **active OMP agent directory's `mcp.json`** (normally `~/.omp/agent/mcp.json`). With `omp --profile <name>`, perform setup, login, status, and logout in that same profile. Credential IDs are derived from the active agent directory and Figma endpoint, so authentication is not automatically shared between profiles. The plugin does not write project-local MCP configuration.
103
+ The server registration comes from the package's **`.mcp.json`**, not a setup-created user configuration. With `omp --profile <name>`, perform setup, login, status, and logout in that same profile. OMP's native credential key is derived from the active profile and Figma endpoint (`mcp_oauth:profile:<profile>:https://mcp.figma.com/mcp`, using `default` for the default profile), so authentication is not automatically shared between profiles. The agent-directory-derived credential ID is used only for legacy migration and cleanup. The plugin does not write project-local MCP configuration.
78
104
 
79
- Access/refresh tokens and client registration data are saved through **OMP's native AuthStorage**. The `mcp.json` entry contains the server URL, transport type, and an OAuth `credentialId` reference, not the tokens. OMP handles subsequent token refresh.
105
+ Access/refresh tokens and client registration data are saved through **OMP's native AuthStorage** under that profile/URL key. The package's `.mcp.json` declares the server URL and HTTP transport without tokens or a custom `credentialId` reference; OMP resolves the native credentials and handles subsequent token refresh. Setup copies an owned legacy credential only when no native credential exists, preserves an existing owned native credential, and removes the owned legacy credential after successful migration. Credentials managed by another authentication source are not overwritten.
80
106
 
81
107
  Figma's [official rate limits and access documentation](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/) currently gives Starter users **up to 20 calls per month for tools that read data from Figma**, with some tools exempt. Limits depend on plan and seat, can change, and do not replace file permissions. This plugin does not increase or bypass quotas.
82
108
 
83
109
  ## Troubleshooting
84
110
 
85
111
  - **Unknown slash command:** check `omp plugin list` in the terminal, then restart OMP or run `/reload-plugins`. Use `/figma-remote-auth ...` in the TUI; there is no `omp figma-remote-auth` CLI command. `/mcp ...` commands also belong in the TUI.
86
- - **Existing configuration conflict:** setup refuses to overwrite a conflicting `mcpServers.figma` entry. Back up the active agent directory's `mcp.json`, then inspect that entry for a different URL/type, an `Authorization` header, another `auth` source, or old transport/token options such as `command`, `args`, `env`, or `oauth`. If migrating, remove or rename the obsolete entry after reviewing it, then rerun setup/login. Preserve unrelated servers. Also check project MCP configurations if OMP still resolves a different `figma` server. A credential ownership conflict requires resolving the other authentication source; the plugin will not overwrite it.
87
- - **Browser callback fails or login stalls:** keep the browser and OMP on the same machine; a browser on your laptop cannot directly reach a remote SSH/container loopback listener. Check local port access, cancel the attempt, and retry with `--port 0` or an available fixed port. A failed login may leave setup in place without a saved credential.
112
+ - **Existing configuration conflict:** setup automatically removes only this plugin's exact legacy `mcpServers.figma` entry: `type: "http"`, `url: "https://mcp.figma.com/mcp"`, and `auth` containing only `type: "oauth"` and this plugin's legacy `credentialId`, with no extra fields. Any custom or different entry requires explicit review. Back up the active agent directory's `mcp.json`, then inspect the entry for a different URL/type, an `Authorization` header, another `auth` source, extra fields, or old transport/token options such as `command`, `args`, `env`, or `oauth`. Remove or rename an obsolete entry only after reviewing it, then rerun setup/login and `/mcp reload` or restart OMP. Preserve unrelated servers. Also inspect independent project MCP configurations and sources loaded through `-e` or extension directories if OMP still resolves a different `figma` server; setup does not remove those sources. A credential ownership conflict requires resolving the other authentication source; the plugin will not overwrite it.
113
+ - **Browser callback fails or login stalls:** keep the browser and OMP on the same machine; a browser on your laptop cannot directly reach a remote SSH/container loopback listener. Check local port access, cancel the attempt, and retry with `--port 0` or an available fixed port. A failed login may leave legacy migration complete without a newly saved credential.
88
114
  - **Authentication still fails:** run `/figma-remote-auth status`, log in again with `/figma-remote-auth login`, then `/mcp reload` and `/mcp test figma`. OMP's `/mcp reauth figma` is its own generic OAuth flow; it does **not** invoke this plugin's login or its client-name registration behavior.
89
115
  - **Permission or quota errors:** check the authorized Figma account, file access, plan, and seat against the official limits above; logging in again does not add quota.
90
116
 
91
117
  ## Logout and uninstall
92
118
 
93
- Installing or linking the plugin does not log you in. Uninstalling it does not automatically clear credentials or remove the `figma` MCP configuration.
119
+ Installing or linking the plugin does not log you in. Uninstalling it and restarting OMP removes the package-provided `figma` MCP registration without requiring logout first or manual removal from `mcp.json`. Credentials remain in OMP for reuse after reinstalling in the same profile. For a legacy installation, complete the setup migration above before relying on this package-managed lifecycle; custom configuration conflicts still require explicit review.
94
120
 
95
- For credential cleanup, run `/figma-remote-auth logout` and `/mcp reload` in each profile you authorized **before uninstalling**. Logout deletes the local credential only; it does **not revoke the server-side authorization at Figma**. To revoke that authorization, remove the corresponding app authorization in your Figma account settings.
121
+ For optional credential cleanup, run `/figma-remote-auth logout` and `/mcp reload` in each profile you authorized while the plugin is installed. Logout deletes only this plugin's owned local native profile/URL credential and owned legacy agent-directory credential; it leaves registration and credentials belonging to other authentication sources untouched. It does **not revoke the server-side authorization at Figma**. To revoke that authorization, remove the corresponding app authorization in your Figma account settings.
96
122
 
97
123
  Uninstall from the terminal:
98
124
 
@@ -100,7 +126,7 @@ Uninstall from the terminal:
100
126
  omp plugin uninstall omp-figma-remote-auth
101
127
  ```
102
128
 
103
- Restart OMP or run `/reload-plugins`. If you also want to remove the server, delete only its `mcpServers.figma` entry from the relevant `mcp.json` and run `/mcp reload`. You can delete the clone after unlinking/uninstalling it. If already uninstalled, link it again to use logout in the original profile.
129
+ Restart OMP to stop loading the package's MCP registration. Independent project configuration or sources loaded through `-e` or extension directories can still contribute a `figma` server; uninstall does not remove them. Review those sources explicitly if you also want to remove their registrations. You can delete the clone after unlinking/uninstalling it. If already uninstalled and you want to clear retained credentials, reinstall or link the plugin again and run logout in the original profile.
104
130
 
105
131
  ## Development and credits
106
132
 
package/README.zh-CN.md CHANGED
@@ -7,19 +7,26 @@
7
7
  ## 使用要求
8
8
 
9
9
  - **OMP 18.1.17 是目前验证过的版本**。建议使用 18.1.17+,但不保证未来版本兼容。
10
- - Git、拥有目标文件访问权限的 Figma 账号,以及与 OMP 运行在同一台机器上的浏览器,用于接收本地 OAuth 回调。无需 Figma 桌面客户端。
10
+ - 拥有目标文件访问权限的 Figma 账号,以及与 OMP 运行在同一台机器上的浏览器,用于接收本地 OAuth 回调。无需 Figma 桌面客户端。仅源码安装需要 Git。
11
11
  - 零运行时包依赖,无需 `pi-mcp-adapter`、`npm install` 或构建。开发测试需要 Node.js 22.6.0+ 和 npm。
12
+ - OMP 的 npm 安装功能要求 `PATH` 中有独立的 `bun` 命令。打包好的 OMP 可执行文件不一定附带它,请参阅 [Bun 安装说明](https://bun.com/docs/installation)。这是安装器的要求,不是插件的包依赖。
12
13
 
13
14
  ## 安装与连接
14
15
 
15
16
  在**终端(CLI)**中执行以下命令,从 npm 安装:
16
17
 
17
18
  ```sh
18
- omp install omp-figma-remote-auth
19
+ omp plugin install omp-figma-remote-auth@latest
19
20
  ```
20
21
 
21
22
  重启 OMP,或在已有 OMP 会话中输入 `/reload-plugins`。无需克隆源码,也无需额外执行 `npm install -g`。
22
23
 
24
+ 如果 OMP 提示 `Executable not found in $PATH: "bun"`,请先安装 Bun。已有 Node.js/npm 时,也可临时提供 Bun 来执行安装:
25
+
26
+ ```sh
27
+ npm exec --yes --package=bun -- omp plugin install omp-figma-remote-auth@latest
28
+ ```
29
+
23
30
  如需开发插件或从源码安装:
24
31
 
25
32
  ```sh
@@ -29,6 +36,18 @@ omp plugin link ./omp-figma-remote-auth
29
36
 
30
37
  源码安装需要保留克隆目录的位置,OMP 会链接到该目录。链接后重启 OMP 或运行 `/reload-plugins`。
31
38
 
39
+ 使用包内 MCP 注册需要安装或链接完整插件包。通过 `omp -e` 仅加载 `index.ts` 适合测试认证命令,不能替代插件安装。`/figma-remote-auth status` 成功只能确认本地凭据状态,不代表 OMP 已发现或连接 MCP 服务。正常使用时,安装插件后直接启动 `omp`,再执行 `/mcp reload` 和 `/mcp test figma`。
40
+
41
+ 从已有开发链接切换到 registry 包时:
42
+
43
+ 1. 使用 `realpath /absolute/path/to/omp-figma-remote-auth` 记录源码目录的规范路径,并记下 OMP plugins 目录(通常为 `~/.omp/plugins`)中的插件安装路径。
44
+ 2. 执行 `omp plugin uninstall omp-figma-remote-auth`,再执行 `omp plugin list`。确认列表中已不包含 `omp-figma-remote-auth`;如果仍存在,请停止。
45
+ 3. 如果插件安装路径仍存在,先检查它。只有确认它是残留符号链接,且其 `realpath` 等于已记录的源码目录路径时,才可以用 `unlink /absolute/path/to/installed/plugin` 仅移除该符号链接。绝不能递归删除目标目录。遇到任何其他目录或链接冲突(包括无法确认目标的链接),请停止并排查。
46
+ 4. 执行 `omp plugin install omp-figma-remote-auth@latest` 安装;如果 PATH 中没有 Bun,则执行 `npm exec --yes --package=bun -- omp plugin install omp-figma-remote-auth@latest`。
47
+ 5. 执行 `omp plugin list` 确认安装,再执行 `realpath /absolute/path/to/installed/plugin`。解析后的路径必须不同于已记录的源码目录路径;如果相同或无法解析,请停止。Registry 安装可以正常使用指向包缓存的符号链接;这本身不是错误,也不是解除链接的理由。
48
+
49
+ 请将示例绝对路径替换为实际路径。迁移后重启 OMP 或运行 `/reload-plugins`。
50
+
32
51
  以下是 **OMP 交互界面(TUI)中的斜杠命令**,不能直接在 shell 中运行。需要时先用 `omp` 启动 OMP,然后执行:
33
52
 
34
53
  ```text
@@ -36,9 +55,13 @@ omp plugin link ./omp-figma-remote-auth
36
55
  /figma-remote-auth login
37
56
  ```
38
57
 
39
- `setup` 只配置服务,不登录。此处可以省略,因为 `login` 会自动执行 setup。
58
+ 包内的 `.mcp.json` 提供 `figma` HTTP MCP 服务。全新安装不会写入用户 `mcp.json`。`setup` 检查冲突并迁移与本插件旧格式完全匹配的注册项及属于本插件的凭据,不执行登录;`login` 会自动执行 setup。
59
+
60
+ 从旧版升级时,请在每个受影响的 profile 中执行 `/figma-remote-auth setup`,然后执行 `/mcp reload` 或重启 OMP。Setup 仅从该 profile 的 agent 目录下的 `mcp.json` 中移除与本插件旧格式完全匹配的 `mcpServers.figma` 条目,并将属于本插件的凭据迁移到 OMP 原生的 profile/URL 凭据键。OMP 可能在 setup 执行前就尝试首次 MCP 连接,因此首次连接仍可能使用旧注册项,或尚未取得迁移后的凭据。迁移后需重新加载 MCP 或重启,使变更生效。
40
61
 
41
- 在同一台机器的浏览器中手动打开输出的授权链接。默认设置下,Figma 授权页显示的应用名称是 **Codex**,原因见下方认证机制说明。确认授权并保持 OMP 运行,浏览器会回调 `http://127.0.0.1:<port>/callback`。返回 OMP 确认凭据已保存;浏览器收到授权码并不代表令牌交换已经成功。
62
+ 在交互 TUI 中,login 默认打开系统浏览器,并在编辑器上方保留加粗高亮的 **点击这里授权 Figma** 链接。浏览器未自动打开时,可点击该入口,或将完整短地址复制到同一台机器的浏览器中。`http://127.0.0.1:<port>/a/<随机标识>` 本机短入口会携带完整 OAuth 参数跳转到 Figma;终端不再输出原始长授权 URL。终端不支持或关闭超链接时,仍会显示完整短 URL 供复制。
63
+
64
+ 默认设置下,Figma 授权页显示的应用名称是 **Codex**,原因见下方认证机制说明。确认授权并保持 OMP 运行,浏览器会回调 `http://127.0.0.1:<port>/callback`。返回 OMP 确认凭据已保存;浏览器打开或收到授权码均不代表令牌交换已经成功。等待期间可重复点击入口;成功、失败、超时、取消或退出 OMP 后,入口和提示组件会关闭。
42
65
 
43
66
  OMP 提示成功后,在 TUI 中执行:
44
67
 
@@ -56,43 +79,46 @@ OMP 提示成功后,在 TUI 中执行:
56
79
  | 命令 | 用途 |
57
80
  | --- | --- |
58
81
  | `/figma-remote-auth help` | 查看帮助;不带子命令时也会显示帮助。 |
59
- | `/figma-remote-auth setup` | 添加或合并原生 `figma` HTTP MCP 配置。 |
60
- | `/figma-remote-auth login` | 自动 setup、动态注册客户端,并显示浏览器授权链接。 |
61
- | `/figma-remote-auth status` | 检查本地配置、凭据是否存在及到期时间、当前操作状态;不进行实际连接测试。 |
62
- | `/figma-remote-auth logout` | 仅删除当前 profile 中属于本插件的凭据,保留 MCP 配置。 |
82
+ | `/figma-remote-auth setup` | 检查冲突,将完全匹配且属于本插件的旧注册项/凭据迁移为包内 MCP 注册和原生凭据;不创建用户 MCP 配置。 |
83
+ | `/figma-remote-auth login` | 自动 setup、动态注册客户端、打开浏览器,并显示高亮本机授权入口。 |
84
+ | `/figma-remote-auth status` | 显示包内 MCP 注册、旧注册项的待迁移/冲突状态、原生凭据是否存在及到期时间、当前操作状态;不进行实际连接测试。 |
85
+ | `/figma-remote-auth logout` | 仅删除当前 profile/agent 目录中属于本插件的本地原生及旧版凭据;保留注册项,不撤销 Figma 授权。 |
63
86
  | `/figma-remote-auth cancel` | 取消正在等待的授权,关闭回调监听。 |
64
87
 
65
- `login` 支持 `--client-name`(默认 `Codex`,1–128 个可打印字符,不能全为空白)与 `--port`(默认 `0`,由操作系统选择可用端口;有效范围为 0–65535)。例如:
88
+ `login` 支持 `--client-name`(默认 `Codex`,1–128 个可打印字符,不能全为空白)、`--port`(默认 `0`,由操作系统选择可用端口;有效范围为 0–65535)及无需取值的 `--no-browser` 旗标,用于手动打开短入口。选项可任意混排,重复选项会报错。RPC 和 print 模式仅提供短 URL,不会自动打开浏览器。例如:
66
89
 
67
90
  ```text
68
91
  /figma-remote-auth login --client-name Codex --port 19876
92
+ /figma-remote-auth login --no-browser
69
93
  ```
70
94
 
71
95
  含空格的名称需加引号。Figma 可能拒绝其他客户端名称。登录会在 10 分钟后超时;执行 `cancel` 或退出 OMP 也会结束等待。setup、login 或 logout 后均应运行 `/mcp reload`,使 OMP 加载变更。
72
96
 
97
+ 通过 SSH 或容器运行时,可用 `--no-browser` 避免在远程主机打开浏览器。回调和短入口仍要求浏览器能够访问该主机的回环接口;插件不会自动建立公网代理或端口转发。如果 OMP 版本缺少 widget API,会提示宿主不受支持,并以纯文本提供完整短 URL。
98
+
73
99
  ## 认证机制、profile 与限额
74
100
 
75
101
  每次登录默认向 Figma 动态客户端注册端点发送 `client_name: Codex`,获取一个**新的 `client_id`**,再通过带 PKCE S256 和 state 校验的 OAuth 授权码流程登录。插件不会复用或偷用已有 Codex 的客户端 ID、密钥或账号令牌。显示名称也不代表这是官方 Codex 集成。这是针对 Figma 客户端准入策略的兼容方案;Figma 可能修改注册规则或端点,使其失效。参见 Figma 的[客户端访问策略](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/#which-mcp-clients-are-supported)。
76
102
 
77
- Setup 写入**当前 OMP agent 目录下的 `mcp.json`**(通常为 `~/.omp/agent/mcp.json`)。使用 `omp --profile <name>` 时,应在同一个 profile 中执行 setup、login、status 和 logout。凭据 ID 根据当前 agent 目录和 Figma 端点生成,因此各 profile 不会自动共享登录状态。插件不写入项目级 MCP 配置。
103
+ 服务注册来自包内的 **`.mcp.json`**,无需 setup 创建用户配置。使用 `omp --profile <name>` 时,应在同一个 profile 中执行 setup、login、status 和 logout。OMP 原生凭据键根据当前 profile Figma 端点生成(`mcp_oauth:profile:<profile>:https://mcp.figma.com/mcp`,默认 profile 使用 `default`),因此各 profile 不会自动共享登录状态。根据 agent 目录生成的凭据 ID 仅用于旧版迁移和清理。插件不写入项目级 MCP 配置。
78
104
 
79
- 访问令牌、刷新令牌及客户端注册信息通过 **OMP 原生 AuthStorage** 保存。`mcp.json` 中仅保存服务 URL、传输类型和 OAuth `credentialId` 引用,不保存令牌。后续令牌刷新由 OMP 处理。
105
+ 访问令牌、刷新令牌及客户端注册信息通过 **OMP 原生 AuthStorage** 保存在对应的 profile/URL 凭据键下。包内的 `.mcp.json` 声明服务 URL HTTP 传输,不包含令牌或自定义 `credentialId` 引用;OMP 负责查找原生凭据并处理后续令牌刷新。Setup 仅在没有原生凭据时复制属于本插件的旧版凭据,保留已有且属于本插件的原生凭据,并在迁移成功后删除属于本插件的旧版凭据。由其他认证来源管理的凭据不会被覆盖。
80
106
 
81
107
  Figma 的[官方限额与访问说明](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/)目前规定:Starter 用户对**从 Figma 读取数据的工具,每月最多调用 20 次**,部分工具不计入该限额。额度取决于套餐和席位,可能变更,且不替代文件访问权限。本插件不会增加或绕过额度。
82
108
 
83
109
  ## 常见问题
84
110
 
85
111
  - **找不到斜杠命令:**在终端用 `omp plugin list` 检查插件,再重启 OMP 或运行 `/reload-plugins`。`/figma-remote-auth ...` 应在 TUI 中输入,不存在 `omp figma-remote-auth` CLI 命令。`/mcp ...` 同样属于 TUI 命令。
86
- - **已有配置冲突:**setup 不会覆盖有冲突的 `mcpServers.figma`。先备份当前 agent 目录的 `mcp.json`,检查该条目是否存在不同的 URL/type、`Authorization` 请求头、其他 `auth` 来源,或 `command`、`args`、`env`、`oauth` 等旧传输或令牌选项。迁移时,确认后删除或重命名旧条目,再执行 setup/login,保留其他服务。如果 OMP 仍加载了另一个 `figma` 服务,还需检查项目级 MCP 配置。若提示凭据归属冲突,应先解决其他认证来源的占用,插件不会覆盖它。
87
- - **浏览器回调失败或一直等待:**浏览器和 OMP 应运行在同一台机器上;笔记本浏览器无法直接连接远程 SSH 主机或容器的回环监听。检查本地端口访问,取消当前操作后用 `--port 0` 或一个可用的固定端口重试。登录失败后,setup 配置可能已写入,但凭据尚未保存。
112
+ - **已有配置冲突:**setup 仅自动移除与本插件旧格式完全匹配的 `mcpServers.figma` 条目:`type: "http"`、`url: "https://mcp.figma.com/mcp"`,以及仅包含 `type: "oauth"` 和本插件旧版 `credentialId` 的 `auth`,不能有额外字段。任何自定义或不同的条目都需要明确检查。先备份当前 agent 目录的 `mcp.json`,检查该条目是否存在不同的 URL/type、`Authorization` 请求头、其他 `auth` 来源、额外字段,或 `command`、`args`、`env`、`oauth` 等旧传输或令牌选项。仅在检查确认后删除或重命名过时条目,再执行 setup/login,然后运行 `/mcp reload` 或重启 OMP,保留其他服务。如果 OMP 仍加载了另一个 `figma` 服务,还需检查独立的项目级 MCP 配置,以及通过 `-e` 或扩展目录加载的来源;setup 不会移除这些来源。若提示凭据归属冲突,应先解决其他认证来源的占用,插件不会覆盖它。
113
+ - **浏览器回调失败或一直等待:**浏览器和 OMP 应运行在同一台机器上;笔记本浏览器无法直接连接远程 SSH 主机或容器的回环监听。检查本地端口访问,取消当前操作后用 `--port 0` 或一个可用的固定端口重试。登录失败时,旧版迁移可能已完成,但尚未保存新的凭据。
88
114
  - **仍然认证失败:**运行 `/figma-remote-auth status`,用 `/figma-remote-auth login` 重新登录,然后执行 `/mcp reload` 与 `/mcp test figma`。OMP 的 `/mcp reauth figma` 走其自身通用 OAuth 流程,**不会调用本插件的 login,也不会使用本插件的客户端名称注册逻辑**。
89
115
  - **权限或额度错误:**检查授权的 Figma 账号、文件权限、套餐和席位,并参照上方官方限额;重新登录不会增加额度。
90
116
 
91
117
  ## 退出登录与卸载
92
118
 
93
- 安装或链接插件不会自动登录;卸载插件也不会自动清除凭据或删除 `figma` MCP 配置。
119
+ 安装或链接插件不会自动登录。卸载插件并重启 OMP 后,包提供的 `figma` MCP 注册项会随之移除,无需提前 logout,也无需手动从 `mcp.json` 中删除条目。凭据保留在 OMP 中,便于在同一 profile 下重新安装后复用。旧版安装应先完成上文的 setup 迁移,再依赖这一由包管理注册项的生命周期;自定义配置冲突仍需明确检查。
94
120
 
95
- 如需清理凭据,请在**卸载前**进入每个曾授权的 profile,执行 `/figma-remote-auth logout` 和 `/mcp reload`。Logout 仅删除本地凭据,**不会撤销 Figma 服务器端授权**。如需撤销,请在 Figma 账号设置中移除对应应用的授权。
121
+ 如需额外清理凭据,可在插件仍安装时,进入每个曾授权的 profile,执行 `/figma-remote-auth logout` 和 `/mcp reload`。Logout 仅删除属于本插件的本地原生 profile/URL 凭据及旧版 agent 目录凭据,保留注册项和其他认证来源的凭据,**不会撤销 Figma 服务器端授权**。如需撤销,请在 Figma 账号设置中移除对应应用的授权。
96
122
 
97
123
  在终端执行卸载:
98
124
 
@@ -100,7 +126,7 @@ Figma 的[官方限额与访问说明](https://developers.figma.com/docs/figma-m
100
126
  omp plugin uninstall omp-figma-remote-auth
101
127
  ```
102
128
 
103
- 重启 OMP 或运行 `/reload-plugins`。如需同时删除服务,仅从对应的 `mcp.json` 中删除 `mcpServers.figma` 条目,再运行 `/mcp reload`。解除链接或卸载后可以删除克隆目录。如果已经卸载,可重新链接插件,再进入原 profile 执行 logout。
129
+ 重启 OMP,使其停止加载包内的 MCP 注册项。独立的项目配置,以及通过 `-e` 或扩展目录加载的来源,仍可能提供 `figma` 服务;卸载不会移除这些来源。如需同时移除它们的注册项,请明确检查对应来源。解除链接或卸载后可以删除克隆目录。如果已经卸载且想清理保留的凭据,可重新安装或链接插件,再进入原 profile 执行 logout。
104
130
 
105
131
  ## 开发与致谢
106
132
 
package/index.ts CHANGED
@@ -1,21 +1,32 @@
1
1
  import { COMMAND_NAME, FIGMA_URL, UserError, formatError, helpText, parseArgs } from "./src/args.ts";
2
- import { configStatus, setupConfig } from "./src/config.ts";
2
+ import { isAuthorizationEntry, openBrowser } from "./src/browser.ts";
3
+ import type { Exec } from "./src/browser.ts";
4
+ import { inspectLegacyConfig } from "./src/config.ts";
5
+ import { migrateLegacySetup } from "./src/migration.ts";
3
6
  import { runOAuthFlow } from "./src/oauth.ts";
4
7
  import type { FlowOptions, OAuthGrant } from "./src/oauth.ts";
5
- import { credentialIdFor, credentialStatus, logout, ownedCredential, saveCredential } from "./src/storage.ts";
8
+ import { legacyCredentialIdFor, nativeCredentialIdFor, resolveActiveProfile, credentialStatus, logout, saveCredential } from "./src/storage.ts";
6
9
  import type { AuthStorage } from "./src/storage.ts";
7
10
 
8
11
  /** Minimal structural interface; OMP supplies all runtime services, no SDK dependency. */
9
12
  export interface CommandContext {
10
13
  signal?: AbortSignal;
14
+ mode?: string;
15
+ hasUI?: boolean;
11
16
  modelRegistry: { authStorage: AuthStorage };
12
17
  ui: {
13
18
  notify(message: string, level: "info" | "error"): void;
14
19
  setStatus?(key: string, message: string | undefined): void;
20
+ setWidget?(key: string, factory: (() => unknown) | undefined, options?: { placement: "aboveEditor" }): void;
15
21
  };
16
22
  }
17
23
  export interface ExtensionAPI {
18
- pi: { getAgentDir(): string };
24
+ pi: {
25
+ getAgentDir(): string;
26
+ Markdown?: new (text: string, paddingX: number, paddingY: number, theme: unknown) => unknown;
27
+ getMarkdownTheme?: () => unknown;
28
+ };
29
+ exec?: Exec;
19
30
  registerCommand(name: string, command: {
20
31
  description: string;
21
32
  handler(args: string, ctx: CommandContext): Promise<void>;
@@ -24,9 +35,15 @@ export interface ExtensionAPI {
24
35
  }
25
36
  export interface Dependencies {
26
37
  runOAuth?: (options: FlowOptions) => Promise<OAuthGrant>;
38
+ platform?: NodeJS.Platform;
27
39
  }
28
40
  // One active mutation across extension instances in this process, including during a reload.
29
- let active: { controller: AbortController; done: Promise<void> } | undefined;
41
+ let active: { controller: AbortController; done: Promise<void>; cleanup: () => void } | undefined;
42
+
43
+ // Host UI failures must not interrupt cancellation, listener cleanup, or completion.
44
+ function tryUI(action: () => void): void {
45
+ try { action(); } catch { /* The host may already be disposing its UI. */ }
46
+ }
30
47
 
31
48
  export default function figmaRemoteAuthExtension(pi: ExtensionAPI): void {
32
49
  registerExtension(pi);
@@ -36,6 +53,7 @@ export function registerExtension(pi: ExtensionAPI, dependencies: Dependencies =
36
53
  pi.on("session_shutdown", async () => {
37
54
  shuttingDown = true;
38
55
  const pending = active;
56
+ pending?.cleanup();
39
57
  pending?.controller.abort();
40
58
  await pending?.done;
41
59
  });
@@ -45,25 +63,31 @@ export function registerExtension(pi: ExtensionAPI, dependencies: Dependencies =
45
63
  let finish: (() => void) | undefined;
46
64
  let cancel: (() => void) | undefined;
47
65
  let operation: typeof active;
66
+ const notify = (message: string, level: "info" | "error") => tryUI(() => ctx.ui.notify(message, level));
48
67
  try {
49
68
  const command = parseArgs(args);
50
- if (command.kind === "help") { ctx.ui.notify(helpText(), "info"); return; }
69
+ if (command.kind === "help") { notify(helpText(), "info"); return; }
51
70
  if (command.kind === "cancel") {
52
71
  const pending = active;
72
+ pending?.cleanup();
53
73
  pending?.controller.abort();
54
74
  await pending?.done;
55
- ctx.ui.notify(pending ? "Figma authorization cancelled." : "No Figma authorization is running.", "info");
75
+ notify(pending ? "Figma authorization cancelled." : "No Figma authorization is running.", "info");
56
76
  return;
57
77
  }
58
78
  if (shuttingDown) throw new UserError("OMP is shutting down; authentication is unavailable.");
59
79
  const agentDir = pi.pi.getAgentDir();
60
- const credentialId = credentialIdFor(agentDir);
80
+ const profile = resolveActiveProfile();
81
+ const credentialId = nativeCredentialIdFor(profile);
82
+ const legacyId = legacyCredentialIdFor(agentDir);
61
83
  const storage = ctx.modelRegistry.authStorage;
62
84
  if (command.kind === "status") {
63
- const config = configStatus(agentDir, credentialId);
85
+ const config = inspectLegacyConfig(agentDir, legacyId);
64
86
  ctx.ui.notify([
65
87
  `Figma Remote MCP: ${FIGMA_URL}`,
66
- `Config: ${config.configured ? "configured" : "setup required"}.`,
88
+ "Config: package-provided MCP definition; runtime discovery and connection are not checked here.",
89
+ "Install or link the full plugin package for MCP registration; loading index.ts alone is not an installation check.",
90
+ `Legacy config: ${config.kind === "owned" ? "migration pending" : config.kind === "conflict" ? `conflict at ${config.path}; resolve before setup or login` : "absent"}.`,
67
91
  credentialStatus(storage, credentialId),
68
92
  `Authorization: ${active ? "operation in progress" : "idle"}.`,
69
93
  ].join("\n"), "info");
@@ -72,47 +96,94 @@ export function registerExtension(pi: ExtensionAPI, dependencies: Dependencies =
72
96
  if (active) throw new UserError("A Figma authentication operation is already running. Cancel it before starting another.");
73
97
  const controller = new AbortController();
74
98
  const done = new Promise<void>(resolve => { finish = resolve; });
75
- operation = { controller, done };
99
+ let cleaned = false;
100
+ let widgetShown = false;
101
+ let statusShown = false;
102
+ let entryShown = false;
103
+ const current = {
104
+ controller, done,
105
+ cleanup: () => {
106
+ if (cleaned || active !== current) return;
107
+ cleaned = true;
108
+ if (widgetShown) tryUI(() => ctx.ui.setWidget?.(COMMAND_NAME, undefined));
109
+ if (statusShown && active === current) tryUI(() => ctx.ui.setStatus?.(COMMAND_NAME, undefined));
110
+ },
111
+ };
112
+ operation = current;
76
113
  active = operation;
77
- cancel = () => controller.abort();
114
+ cancel = () => { current.cleanup(); controller.abort(); };
78
115
  ctx.signal?.addEventListener("abort", cancel, { once: true });
79
116
  if (ctx.signal?.aborted) cancel();
80
117
  if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
81
118
  if (command.kind === "logout") {
82
- const removed = await logout(storage, credentialId);
83
- ctx.ui.notify(`${removed ? "Removed this plugin's Figma credential." : "No credential owned by this plugin was found."} Run /mcp reload.`, "info");
119
+ const removed = await logout(storage, credentialId, legacyId);
120
+ notify(`${removed ? "Removed this plugin's Figma credential." : "No credential owned by this plugin was found."} Run /mcp reload.`, "info");
84
121
  return;
85
122
  }
86
- // Protect credential ownership before setup changes the configured auth source.
87
- ownedCredential(storage, credentialId);
88
- setupConfig(agentDir, credentialId);
123
+ const migration = await migrateLegacySetup(agentDir, storage, profile);
124
+ if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
89
125
  if (command.kind === "setup") {
90
- ctx.ui.notify("Figma MCP configured. Use /figma-remote-auth login to authorize, then run /mcp reload.", "info");
126
+ if (migration.configRemoved || migration.credentialMigrated) {
127
+ notify("Legacy Figma setup migrated to native profile credentials and package-managed MCP registration.", "info");
128
+ }
129
+ notify("Figma MCP is managed by this plugin. Run /mcp reload.", "info");
91
130
  return;
92
131
  }
93
- ctx.ui.setStatus?.(COMMAND_NAME, "Waiting for Figma authorization");
132
+ statusShown = true;
133
+ tryUI(() => ctx.ui.setStatus?.(COMMAND_NAME, "Waiting for Figma authorization"));
94
134
  const grant = await (dependencies.runOAuth ?? runOAuthFlow)({
95
135
  clientName: command.clientName, port: command.port, signal: controller.signal,
96
- onAuthorizationUrl: (url) => {
97
- ctx.ui.notify(`Open this URL in your browser to authorize Figma:\n${url}\n\nReturn to OMP when finished. Use /figma-remote-auth cancel to stop waiting.`, "info");
136
+ onAuthorizationReady: async ({ startUrl, callbackUrl }) => {
137
+ if (controller.signal.aborted || cleaned || active !== current || entryShown) return;
138
+ if (!isAuthorizationEntry(startUrl, callbackUrl)) throw new UserError("Invalid local Figma authorization entry.");
139
+ entryShown = true;
140
+ const interactive = ctx.hasUI === true && ctx.mode === "tui";
141
+ const Markdown = pi.pi.Markdown;
142
+ const getMarkdownTheme = pi.pi.getMarkdownTheme;
143
+ let displayed = false;
144
+ if (interactive && ctx.ui.setWidget && Markdown && getMarkdownTheme) {
145
+ try {
146
+ widgetShown = true;
147
+ ctx.ui.setWidget(COMMAND_NAME, () => new Markdown(
148
+ '**[点击这里授权 Figma](<' + startUrl + '>)**\n\n浏览器未自动打开时,点击上方入口。取消:/figma-remote-auth cancel',
149
+ 1, 0, getMarkdownTheme(),
150
+ ), { placement: "aboveEditor" });
151
+ displayed = true;
152
+ } catch { /* Fall back to the validated plain entry below. */ }
153
+ }
154
+ if (controller.signal.aborted || cleaned || active !== current) return;
155
+ if (!displayed) {
156
+ notify(`Open this local entry in your browser to authorize Figma:\n${startUrl}\n\nUse /figma-remote-auth cancel to stop waiting.`, "info");
157
+ if (interactive) notify("This host does not support the Figma authorization widget. Use the local entry above.", "info");
158
+ }
159
+ if (!interactive || !command.openBrowser || controller.signal.aborted || cleaned || active !== current) return;
160
+ const opened = pi.exec && await openBrowser(startUrl, pi.exec.bind(pi), {
161
+ platform: dependencies.platform ?? process.platform, signal: controller.signal,
162
+ });
163
+ if (!opened && !controller.signal.aborted && !cleaned && active === current) {
164
+ notify("The browser could not be opened automatically. Use the Figma authorization entry above; authorization is still waiting.", "info");
165
+ }
98
166
  },
99
167
  });
100
168
  if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
101
- // Recheck config and ownership after the browser wait before persisting credentials.
102
- const config = configStatus(agentDir, credentialId);
103
- if (!config.configured) throw new UserError("Figma configuration changed during authorization; credentials were not saved.");
169
+ // Recheck the active profile, migration conflicts, and cancellation after the browser wait.
170
+ if (resolveActiveProfile() !== profile) throw new UserError("The active OMP profile changed during authorization; credentials were not saved.");
171
+ await migrateLegacySetup(agentDir, storage, profile);
172
+ if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
104
173
  await saveCredential(storage, credentialId, grant);
105
- ctx.ui.notify("Figma authorization saved. Run /mcp reload.", "info");
174
+ notify("Figma authorization saved. Run /mcp reload.", "info");
106
175
  } catch (error) {
107
- ctx.ui.notify(formatError(error), "error");
176
+ notify(formatError(error), "error");
108
177
  } finally {
109
- if (cancel) ctx.signal?.removeEventListener("abort", cancel);
110
- if (operation) {
111
- operation.controller.abort();
112
- if (active === operation) active = undefined;
113
- // Resolve first: UI failures must never strand session_shutdown waiting for cleanup.
114
- finish?.();
115
- ctx.ui.setStatus?.(COMMAND_NAME, undefined);
178
+ try {
179
+ if (cancel) ctx.signal?.removeEventListener("abort", cancel);
180
+ if (operation && active === operation) operation.cleanup();
181
+ } finally {
182
+ if (operation) {
183
+ operation.controller.abort();
184
+ if (active === operation) active = undefined;
185
+ finish?.();
186
+ }
116
187
  }
117
188
  }
118
189
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-figma-remote-auth",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OAuth helper for Figma remote MCP using OMP native transport",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,7 @@
32
32
  "main": "./index.ts",
33
33
  "exports": "./index.ts",
34
34
  "files": [
35
+ ".mcp.json",
35
36
  "index.ts",
36
37
  "src",
37
38
  "LICENSE",
package/src/args.ts CHANGED
@@ -18,7 +18,7 @@ export function formatError(error: unknown): string {
18
18
 
19
19
  export type Command =
20
20
  | { kind: "help" | "setup" | "status" | "logout" | "cancel" }
21
- | { kind: "login"; clientName: string; port: number };
21
+ | { kind: "login"; clientName: string; port: number; openBrowser: boolean };
22
22
 
23
23
  /** Small shell-style tokenizer; no expansion, execution, or environment access. */
24
24
  function tokenize(input: string): string[] {
@@ -65,17 +65,19 @@ export function parseArgs(input: string): Command {
65
65
  }
66
66
  let clientName = "Codex";
67
67
  let port = 0;
68
+ let openBrowser = true;
68
69
  const seen = new Set<string>();
69
- for (let i = 0; i < args.length; i += 2) {
70
- const key = args[i];
71
- const value = args[i + 1];
72
- if (!key || !["--client-name", "--port"].includes(key) || seen.has(key)) {
70
+ for (let i = 0; i < args.length;) {
71
+ const key = args[i++];
72
+ if (!key || !["--client-name", "--port", "--no-browser"].includes(key) || seen.has(key)) {
73
73
  throw new UserError("Unknown or duplicate login option. Use /figma-remote-auth help.");
74
74
  }
75
+ seen.add(key);
76
+ if (key === "--no-browser") { openBrowser = false; continue; }
77
+ const value = args[i++];
75
78
  if (value === undefined || value.startsWith("--")) {
76
79
  throw new UserError("Login option requires a value.");
77
80
  }
78
- seen.add(key);
79
81
  if (key === "--client-name") {
80
82
  if (!value.trim() || value.length > 128 || /[\x00-\x1f\x7f]/u.test(value)) {
81
83
  throw new UserError("Client name must contain 1–128 printable characters.");
@@ -88,21 +90,23 @@ export function parseArgs(input: string): Command {
88
90
  port = Number(value);
89
91
  }
90
92
  }
91
- return { kind: "login", clientName, port };
93
+ return { kind: "login", clientName, port, openBrowser };
92
94
  }
93
95
 
94
96
  export function helpText(): string {
95
97
  return [
96
98
  "/figma-remote-auth help",
97
99
  "/figma-remote-auth setup",
98
- "/figma-remote-auth login [--client-name Codex] [--port 0]",
100
+ "/figma-remote-auth login [--client-name Codex] [--port 0] [--no-browser]",
99
101
  "/figma-remote-auth status",
100
102
  "/figma-remote-auth logout",
101
103
  "/figma-remote-auth cancel",
102
104
  "",
103
105
  `Server: ${SERVER_NAME} (${FIGMA_URL})`,
104
- "Setup uses the active OMP agent directory's mcp.json.",
105
- "Login displays a link for you to open manually. Use /figma-remote-auth cancel or exit OMP to stop waiting.",
106
+ "Figma MCP is managed by this plugin. Setup migrates owned legacy configuration and credentials.",
107
+ "Login opens your browser in the interactive TUI and displays a highlighted local authorization entry.",
108
+ "Use --no-browser to open the short entry manually; RPC/print never open a browser automatically.",
109
+ "Use /figma-remote-auth cancel or exit OMP to stop waiting and close the local entry.",
106
110
  "OMP handles MCP transport and token refresh. After setup, login, or logout, run /mcp reload.",
107
111
  ].join("\n");
108
112
  }
package/src/browser.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { UserError } from "./args.ts";
2
+
3
+ export type Exec = (
4
+ command: string, args: string[], options: { signal?: AbortSignal; timeout?: number; cwd?: string },
5
+ ) => Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>;
6
+
7
+ /** Accept only the canonical local entry; reject URL parser normalization and hidden suffixes. */
8
+ export function isAuthorizationEntry(url: string, callbackUrl?: string): boolean {
9
+ const match = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})\/a\/[A-Za-z0-9_-]{22}$/u.exec(url);
10
+ if (/[\x00-\x20\x7f]/u.test(url)) return false;
11
+ if (!match || Number(match[1]) > 65535) return false;
12
+ return callbackUrl === undefined || callbackUrl === `http://127.0.0.1:${match[1]}/callback`;
13
+ }
14
+
15
+ function checkCancelled(signal?: AbortSignal): void {
16
+ if (signal?.aborted) throw new UserError("Figma authorization cancelled.");
17
+ }
18
+
19
+ export async function openBrowser(
20
+ url: string, exec: Exec, options: { platform: NodeJS.Platform; signal?: AbortSignal },
21
+ ): Promise<boolean> {
22
+ checkCancelled(options.signal);
23
+ if (!isAuthorizationEntry(url)) return false;
24
+ let command: string;
25
+ let args: string[];
26
+ switch (options.platform) {
27
+ case "darwin": command = "open"; args = [url]; break;
28
+ case "linux": command = "xdg-open"; args = [url]; break;
29
+ case "win32": command = "rundll32.exe"; args = ["url.dll,FileProtocolHandler", url]; break;
30
+ default: return false;
31
+ }
32
+ try {
33
+ const result = await exec(command, args, { timeout: 5000, signal: options.signal });
34
+ checkCancelled(options.signal);
35
+ return result.code === 0 && !result.killed;
36
+ } catch {
37
+ checkCancelled(options.signal);
38
+ return false;
39
+ }
40
+ }
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { constants, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { constants, closeSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { FIGMA_URL, UserError } from "./args.ts";
@@ -7,20 +7,26 @@ type JsonObject = Record<string, unknown>;
7
7
  function record(value: unknown): value is JsonObject {
8
8
  return !!value && typeof value === "object" && !Array.isArray(value);
9
9
  }
10
- export interface ConfigState { configured: boolean; path: string }
10
+ export interface LegacyConfigState { kind: "absent" | "owned" | "conflict"; path: string }
11
11
 
12
- /** Newly created private directories use 0700; normal ancestor links (macOS /tmp) are allowed. */
13
- function privateDirectory(path: string): void {
14
- mkdirSync(path, { recursive: true, mode: 0o700 });
15
- const info = lstatSync(path);
16
- if (!info.isDirectory() || info.isSymbolicLink()) {
17
- throw new UserError("OMP config directory must be a real directory, not a symlink.");
12
+ /** Do not create anything; normal ancestor links (macOS /tmp) are allowed. */
13
+ function checkDirectory(path: string): void {
14
+ try {
15
+ const info = lstatSync(path);
16
+ if (!info.isDirectory() || info.isSymbolicLink()) {
17
+ throw new UserError("OMP config directory must be a real directory, not a symlink.");
18
+ }
19
+ } catch (error) {
20
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
21
+ if (error instanceof UserError) throw error;
22
+ throw new UserError("Unable to inspect the OMP config directory safely; it was not changed.");
18
23
  }
19
24
  }
20
25
  function readConfig(path: string): { raw?: string; config: JsonObject } {
21
26
  let fd: number | undefined;
22
27
  try {
23
- fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
28
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
29
+ if (!fstatSync(fd).isFile()) throw new UserError("OMP mcp.json must be a regular file; it was not changed.");
24
30
  const raw = readFileSync(fd, "utf8");
25
31
  let config: unknown;
26
32
  try { config = JSON.parse(raw); }
@@ -36,58 +42,37 @@ function readConfig(path: string): { raw?: string; config: JsonObject } {
36
42
  }
37
43
  }
38
44
 
39
- /** Validate before mutation, preserving all unrelated JSON fields and servers. */
40
- export function mergeConfig(config: JsonObject, credentialId: string): JsonObject {
45
+ function classify(config: JsonObject, legacyId: string): LegacyConfigState["kind"] {
46
+ if (!Object.hasOwn(config, "mcpServers")) return "absent";
41
47
  const servers = config.mcpServers;
42
- if (servers !== undefined && !record(servers)) {
43
- throw new UserError("mcpServers must be an object; mcp.json was not changed.");
44
- }
45
- const entries = servers ?? {};
46
- const existing = entries.figma;
47
- if (Object.hasOwn(entries, "figma")) {
48
- if (!record(existing) || existing.url !== FIGMA_URL || existing.type !== "http") {
49
- throw new UserError("Existing figma endpoint/type conflicts with official Figma HTTP MCP; mcp.json was not changed.");
50
- }
51
- if (existing.headers !== undefined) {
52
- if (!record(existing.headers) || Object.keys(existing.headers).some(key => key.toLowerCase() === "authorization")) {
53
- throw new UserError("Existing figma headers conflict with OAuth; mcp.json was not changed.");
54
- }
55
- }
56
- if (Object.hasOwn(existing, "auth")) {
57
- const auth = existing.auth;
58
- if (!record(auth) || auth.type !== "oauth" || auth.credentialId !== credentialId ||
59
- Object.keys(auth).some(key => !["type", "credentialId"].includes(key))) {
60
- throw new UserError("Existing figma auth belongs to another source or has conflicting options; mcp.json was not changed.");
61
- }
62
- }
63
- // Do not silently combine a URL transport with alternate endpoint/credential settings.
64
- if (["endpoint", "command", "args", "env", "apiKey", "token", "bearerToken", "oauth"].some(key => Object.hasOwn(existing, key))) {
65
- throw new UserError("Existing figma transport or credential options conflict; mcp.json was not changed.");
66
- }
48
+ if (!record(servers)) throw new UserError("mcpServers must be an object; mcp.json was not changed.");
49
+ if (!Object.hasOwn(servers, "figma")) return "absent";
50
+ const figma = servers.figma;
51
+ if (!record(figma) || Object.keys(figma).length !== 3 ||
52
+ !Object.hasOwn(figma, "type") || figma.type !== "http" ||
53
+ !Object.hasOwn(figma, "url") || figma.url !== FIGMA_URL ||
54
+ !Object.hasOwn(figma, "auth") || !record(figma.auth)) return "conflict";
55
+ const auth = figma.auth;
56
+ return Object.keys(auth).length === 2 && Object.hasOwn(auth, "type") && auth.type === "oauth" &&
57
+ Object.hasOwn(auth, "credentialId") && auth.credentialId === legacyId ? "owned" : "conflict";
58
+ }
59
+ function requireRemovable(kind: LegacyConfigState["kind"], path: string): void {
60
+ if (kind === "conflict") {
61
+ throw new UserError(`Existing figma configuration in ${path} is not the exact legacy entry owned by this plugin. Resolve that configuration before retrying; it was not changed.`);
67
62
  }
68
- return {
69
- ...config,
70
- mcpServers: {
71
- ...entries,
72
- figma: { ...(record(existing) ? existing : {}), type: "http", url: FIGMA_URL, auth: { type: "oauth", credentialId } },
73
- },
74
- };
75
63
  }
76
-
77
- export function configStatus(agentDir: string, credentialId: string): ConfigState {
64
+ export function inspectLegacyConfig(agentDir: string, legacyId: string): LegacyConfigState {
65
+ checkDirectory(agentDir);
78
66
  const path = join(agentDir, "mcp.json");
79
- const { config } = readConfig(path);
80
- mergeConfig(config, credentialId); // Status reports conflicts without revealing values.
81
- const servers = record(config.mcpServers) ? config.mcpServers : {};
82
- const figma = record(servers.figma) ? servers.figma : {};
83
- const auth = record(figma.auth) ? figma.auth : {};
84
- return { configured: auth.credentialId === credentialId, path };
67
+ return { kind: classify(readConfig(path).config, legacyId), path };
85
68
  }
86
69
 
87
- /** Synchronous lock/read/merge/rename keeps local operations in one uninterrupted turn. */
88
- export function setupConfig(agentDir: string, credentialId: string): string {
89
- privateDirectory(agentDir);
90
- const path = join(agentDir, "mcp.json");
70
+ /** Only delete the exact owned legacy entry, preserving the file and every unrelated field. */
71
+ export function removeLegacyConfig(agentDir: string, legacyId: string): boolean {
72
+ const state = inspectLegacyConfig(agentDir, legacyId);
73
+ requireRemovable(state.kind, state.path);
74
+ if (state.kind === "absent") return false; // No lock, directory, or file writes when absent.
75
+ const path = state.path;
91
76
  const lockPath = join(agentDir, ".figma-remote-auth-config.lock");
92
77
  let lock: number;
93
78
  try { lock = openSync(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
@@ -95,28 +80,36 @@ export function setupConfig(agentDir: string, credentialId: string): string {
95
80
  let temporary: string | undefined;
96
81
  let file: number | undefined;
97
82
  try {
83
+ checkDirectory(agentDir);
98
84
  const { raw, config } = readConfig(path);
99
- const merged = mergeConfig(config, credentialId);
100
- if (JSON.stringify(config) === JSON.stringify(merged)) return path;
101
- temporary = join(agentDir, `.mcp.json.${randomBytes(16).toString("hex")}.tmp`);
102
- file = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
103
- writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
85
+ const kind = classify(config, legacyId);
86
+ requireRemovable(kind, path);
87
+ if (kind === "absent") return false;
88
+ const servers = { ...(config.mcpServers as JsonObject) };
89
+ delete servers.figma;
90
+ const remaining = { ...config, mcpServers: servers };
91
+ const candidate = join(agentDir, `.mcp.json.${randomBytes(16).toString("hex")}.tmp`);
92
+ file = openSync(candidate, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
93
+ temporary = candidate;
94
+ writeFileSync(file, `${JSON.stringify(remaining, null, 2)}\n`, "utf8");
104
95
  fsyncSync(file);
105
96
  closeSync(file);
106
97
  file = undefined;
107
- if (readConfig(path).raw !== raw) throw new UserError("mcp.json changed during setup; retry the command.");
98
+ if (readConfig(path).raw !== raw) throw new UserError("mcp.json changed during migration; retry the command.");
108
99
  renameSync(temporary, path);
109
100
  temporary = undefined;
110
- const directory = openSync(agentDir, constants.O_RDONLY);
101
+ const directory = openSync(agentDir, constants.O_RDONLY | constants.O_NOFOLLOW);
111
102
  try { fsyncSync(directory); } finally { closeSync(directory); }
112
- return path;
103
+ return true;
113
104
  } catch (error) {
114
105
  if (error instanceof UserError) throw error;
115
- throw new UserError("Unable to write OMP mcp.json atomically.");
106
+ throw new UserError("Unable to remove the legacy Figma configuration atomically; retry the command.");
116
107
  } finally {
117
108
  if (file !== undefined) closeSync(file);
118
- if (temporary && existsSync(temporary)) unlinkSync(temporary);
119
- closeSync(lock);
120
- unlinkSync(lockPath);
109
+ try {
110
+ if (temporary !== undefined) unlinkSync(temporary);
111
+ } finally {
112
+ try { closeSync(lock); } finally { unlinkSync(lockPath); }
113
+ }
121
114
  }
122
115
  }
@@ -0,0 +1,51 @@
1
+ import { resolve } from "node:path";
2
+ import { UserError } from "./args.ts";
3
+ import { inspectLegacyConfig, removeLegacyConfig } from "./config.ts";
4
+ import { legacyCredentialIdFor, nativeCredentialIdFor, ownedCredential, saveCredential } from "./storage.ts";
5
+ import type { AuthStorage } from "./storage.ts";
6
+
7
+ // AuthStorage has no compare-and-swap API. Prevent overlapping migrations in this process,
8
+ // including different agent directories that target the same native profile credential.
9
+ const migrating = new Set<string>();
10
+ export async function migrateLegacySetup(
11
+ agentDir: string, storage: AuthStorage, profile: string | undefined,
12
+ ): Promise<{ configRemoved: boolean; credentialMigrated: boolean }> {
13
+ const legacyId = legacyCredentialIdFor(agentDir);
14
+ const nativeId = nativeCredentialIdFor(profile);
15
+ const directoryKey = `directory:${resolve(agentDir)}`;
16
+ if (migrating.has(directoryKey) || migrating.has(nativeId)) {
17
+ throw new UserError("A Figma migration is already running. Retry after it finishes.");
18
+ }
19
+ migrating.add(directoryKey);
20
+ migrating.add(nativeId);
21
+ try {
22
+ // Complete the read-only preflight before copying or removing anything.
23
+ const config = inspectLegacyConfig(agentDir, legacyId);
24
+ const legacy = ownedCredential(storage, legacyId);
25
+ const native = ownedCredential(storage, nativeId);
26
+ if (config.kind === "conflict") {
27
+ throw new UserError(`Existing figma configuration in ${config.path} is not the exact legacy entry owned by this plugin. Resolve that configuration before retrying; nothing was migrated.`);
28
+ }
29
+ const legacySnapshot = legacy && JSON.stringify(legacy);
30
+ let credentialMigrated = false;
31
+ if (legacy && !native) {
32
+ await saveCredential(storage, nativeId, { ...legacy });
33
+ credentialMigrated = true;
34
+ }
35
+ // Saving can yield to another writer. Refuse newly foreign or changed legacy data,
36
+ // and never remove the old path unless an owned native credential now exists.
37
+ const currentLegacy = ownedCredential(storage, legacyId);
38
+ const currentNative = ownedCredential(storage, nativeId);
39
+ if ((currentLegacy && JSON.stringify(currentLegacy)) !== legacySnapshot || (legacy && !currentNative)) {
40
+ throw new UserError("Figma credentials changed during migration; retry the command. The legacy setup was retained.");
41
+ }
42
+ // This re-reads under the config lock and refuses any newly conflicting entry.
43
+ // A successful copy can safely survive a removal failure for a subsequent retry.
44
+ const configRemoved = removeLegacyConfig(agentDir, legacyId);
45
+ if (legacy) await storage.remove(legacyId);
46
+ return { configRemoved, credentialMigrated };
47
+ } finally {
48
+ migrating.delete(directoryKey);
49
+ migrating.delete(nativeId);
50
+ }
51
+ }
package/src/oauth.ts CHANGED
@@ -33,7 +33,7 @@ export interface FlowOptions {
33
33
  clientName: string;
34
34
  port: number;
35
35
  signal?: AbortSignal;
36
- onAuthorizationUrl: (url: string, callbackUrl: string) => void | Promise<void>;
36
+ onAuthorizationReady: (entry: { startUrl: string; callbackUrl: string }) => void | Promise<void>;
37
37
  /** Test seams only. No CLI option can change the remote URLs. */
38
38
  fetch?: FetchLike;
39
39
  timeoutMs?: number;
@@ -188,6 +188,7 @@ export async function exchangeCode(
188
188
 
189
189
  export interface CallbackListener {
190
190
  callbackUrl: string;
191
+ setAuthorizationUrl(url: string): string;
191
192
  result: Promise<string>;
192
193
  close: () => Promise<void>;
193
194
  }
@@ -197,6 +198,10 @@ export async function listenForCallback(port: number, state: string, signal: Abo
197
198
  let rejectCode!: (error: unknown) => void;
198
199
  let settled = false;
199
200
  let closing: Promise<void> | undefined;
201
+ const entryPath = `/a/${randomBytes(16).toString("base64url")}`;
202
+ let authorizationUrl: string | undefined;
203
+ let callbackUrl = "";
204
+ let expectedHost = "";
200
205
  const result = new Promise<string>((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
201
206
  // Discovery/DCR can fail before the caller awaits result. Observe rejection immediately.
202
207
  void result.catch(() => {});
@@ -228,8 +233,17 @@ export async function listenForCallback(port: number, state: string, signal: Abo
228
233
  res.end(text);
229
234
  };
230
235
  if (settled) { reply(410, "Authorization already completed."); return; }
236
+ if (req.headers.host !== expectedHost) { reply(400, "Invalid local Host."); return; }
231
237
  if (req.method !== "GET") { reply(405, "GET required."); return; }
232
238
  if (!req.url?.startsWith("/") || req.url.startsWith("//")) { reply(400, "Bad callback."); return; }
239
+ if (req.url === entryPath && authorizationUrl) {
240
+ res.writeHead(302, {
241
+ Location: authorizationUrl, "Cache-Control": "no-store",
242
+ "Referrer-Policy": "no-referrer", Connection: "close",
243
+ });
244
+ res.end();
245
+ return;
246
+ }
233
247
  let url: URL;
234
248
  try { url = new URL(req.url, "http://127.0.0.1"); }
235
249
  catch { reply(400, "Bad callback."); return; }
@@ -276,7 +290,33 @@ export async function listenForCallback(port: number, state: string, signal: Abo
276
290
  if (!address || typeof address === "string") throw new UserError("OAuth callback listener has no port.");
277
291
  signal.addEventListener("abort", onAbort, { once: true });
278
292
  if (signal.aborted) { onAbort(); aborted(signal); }
279
- return { callbackUrl: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`, result, close };
293
+ expectedHost = `127.0.0.1:${address.port}`;
294
+ callbackUrl = `http://${expectedHost}${CALLBACK_PATH}`;
295
+ return {
296
+ callbackUrl, result, close,
297
+ setAuthorizationUrl(value: string): string {
298
+ if (authorizationUrl !== undefined || settled || closing) {
299
+ throw new UserError("Figma authorization entry is already bound or closed.");
300
+ }
301
+ let target: URL;
302
+ try { target = new URL(value); }
303
+ catch { throw new UserError("Invalid Figma authorization target."); }
304
+ const required: Record<string, string | undefined> = {
305
+ response_type: "code", client_id: undefined, redirect_uri: callbackUrl,
306
+ state, code_challenge: undefined, code_challenge_method: "S256",
307
+ };
308
+ if (/[\x00-\x20\x7f]/u.test(value) || target.origin + target.pathname !== ENDPOINTS.authorizationUrl ||
309
+ target.username || target.password || target.hash || value.includes("#") ||
310
+ Object.entries(required).some(([key, expected]) => {
311
+ const values = target.searchParams.getAll(key);
312
+ return values.length !== 1 || !values[0]?.trim() || (expected !== undefined && values[0] !== expected);
313
+ })) {
314
+ throw new UserError("Invalid Figma authorization target.");
315
+ }
316
+ authorizationUrl = value;
317
+ return `http://${expectedHost}${entryPath}`;
318
+ },
319
+ };
280
320
  } catch (error) {
281
321
  await close();
282
322
  throw error;
@@ -308,7 +348,8 @@ export async function runOAuthFlow(options: FlowOptions): Promise<OAuthGrant> {
308
348
  code_challenge: challenge, code_challenge_method: "S256", state,
309
349
  }).toString();
310
350
  aborted(controller.signal);
311
- await abortable(Promise.resolve(options.onAuthorizationUrl(url.href, listener.callbackUrl)), controller.signal);
351
+ const startUrl = listener.setAuthorizationUrl(url.href);
352
+ await abortable(Promise.resolve(options.onAuthorizationReady({ startUrl, callbackUrl: listener.callbackUrl })), controller.signal);
312
353
  const code = await listener.result;
313
354
  aborted(controller.signal);
314
355
  const grant = await exchangeCode(fetcher, client, listener.callbackUrl, code, verifier, controller.signal, options.now);
package/src/storage.ts CHANGED
@@ -15,17 +15,32 @@ export interface AuthStorage {
15
15
  set(provider: string, credential: Credential): Promise<void>;
16
16
  remove(provider: string): Promise<void>;
17
17
  }
18
- export function credentialIdFor(agentDir: string): string {
18
+ export function legacyCredentialIdFor(agentDir: string): string {
19
19
  return `mcp_oauth_omp_figma_${createHash("sha256").update(`${agentDir}\n${FIGMA_URL}`).digest("hex").slice(0, 24)}`;
20
20
  }
21
+ export function nativeCredentialIdFor(profile: string | undefined): string {
22
+ return `mcp_oauth:profile:${profile ?? "default"}:${FIGMA_URL}`;
23
+ }
24
+ export function resolveActiveProfile(env: Record<string, string | undefined> = process.env): string | undefined {
25
+ const profile = (env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE)?.trim();
26
+ if (!profile || profile === "default") return undefined;
27
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(profile) || profile.endsWith(".") ||
28
+ /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/u.test(profile)) {
29
+ throw new UserError("Invalid OMP profile name. Resolve the profile environment setting before retrying.");
30
+ }
31
+ return profile;
32
+ }
33
+ function isOwnedCredential(value: unknown, credentialId: string): value is Credential {
34
+ return !!value && typeof value === "object" && !Array.isArray(value) &&
35
+ (value as Partial<Credential>).type === "oauth" &&
36
+ (value as Partial<Credential>).figmaRemoteAuthOwner === OWNER &&
37
+ (value as Partial<Credential>).figmaRemoteAuthCredentialId === credentialId;
38
+ }
21
39
  export function ownedCredential(storage: AuthStorage, credentialId: string): Credential | undefined {
22
40
  const value = storage.get(credentialId);
23
41
  if (value === undefined || value === null) return undefined;
24
- if (typeof value !== "object" || Array.isArray(value) ||
25
- (value as Partial<Credential>).type !== "oauth" ||
26
- (value as Partial<Credential>).figmaRemoteAuthOwner !== OWNER ||
27
- (value as Partial<Credential>).figmaRemoteAuthCredentialId !== credentialId) {
28
- throw new UserError("The Figma credential ID is occupied by another authentication source. It was not changed.");
42
+ if (!isOwnedCredential(value, credentialId)) {
43
+ throw new UserError("The Figma credential ID is occupied by another authentication source. Resolve that source before retrying; it was not changed.");
29
44
  }
30
45
  return value as Credential;
31
46
  }
@@ -41,14 +56,21 @@ export async function saveCredential(storage: AuthStorage, credentialId: string,
41
56
  ...grant, type: "oauth", figmaRemoteAuthOwner: OWNER, figmaRemoteAuthCredentialId: credentialId,
42
57
  });
43
58
  }
44
- export async function logout(storage: AuthStorage, credentialId: string): Promise<boolean> {
45
- if (!ownedCredential(storage, credentialId)) return false;
46
- await storage.remove(credentialId);
47
- return true;
59
+ export async function logout(storage: AuthStorage, nativeId: string, legacyId: string): Promise<boolean> {
60
+ // Inspect both keys before the first deletion, then recheck after any asynchronous removal.
61
+ const ids = [...new Set([nativeId, legacyId])].filter(id => isOwnedCredential(storage.get(id), id));
62
+ let removed = false;
63
+ for (const id of ids) {
64
+ if (!isOwnedCredential(storage.get(id), id)) continue;
65
+ await storage.remove(id);
66
+ removed = true;
67
+ }
68
+ return removed;
48
69
  }
49
70
  export function credentialStatus(storage: AuthStorage, credentialId: string, now = Date.now()): string {
50
- const credential = ownedCredential(storage, credentialId);
51
- if (!credential) return "Credential: not logged in.";
71
+ const credential = storage.get(credentialId);
72
+ if (credential === undefined || credential === null) return "Credential: not logged in.";
73
+ if (!isOwnedCredential(credential, credentialId)) return "Credential: managed by another authentication source.";
52
74
  const expiry = credential.expires;
53
75
  if (!Number.isSafeInteger(expiry) || expiry <= 0 || expiry > 8.64e15) {
54
76
  return "Credential: present, but expiration metadata is invalid; log in again.";