omp-figma-remote-auth 0.1.1 → 0.1.3
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 +1 -0
- package/README.md +48 -59
- package/README.zh-CN.md +49 -60
- package/index.ts +99 -35
- package/package.json +2 -1
- package/src/args.ts +18 -15
- package/src/browser.ts +40 -0
- package/src/config.ts +60 -67
- package/src/migration.ts +51 -0
- package/src/oauth.ts +44 -3
- package/src/storage.ts +34 -12
package/.mcp.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"mcpServers":{"figma":{"type":"http","url":"https://mcp.figma.com/mcp"}}}
|
package/README.md
CHANGED
|
@@ -2,108 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Connect [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi) to the official Figma Remote MCP server with browser login. OMP handles connections and token refresh. This unofficial plugin is not affiliated with Figma or OpenAI.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Installation and authorization
|
|
8
8
|
|
|
9
|
-
|
|
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.
|
|
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.
|
|
9
|
+
Requires OMP 18.1.17+ (verified on 18.1.17), a Figma account with file access, and a browser on the same machine as OMP. No Figma Desktop or manual MCP configuration is needed.
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Run this command in your **terminal (CLI)** to install from npm:
|
|
11
|
+
### 1. Install in your terminal
|
|
16
12
|
|
|
17
13
|
```sh
|
|
18
|
-
omp install omp-figma-remote-auth
|
|
14
|
+
omp plugin install omp-figma-remote-auth@latest
|
|
19
15
|
```
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
To develop the plugin or install from source instead:
|
|
17
|
+
If `bun` is missing, use Node.js/npm to supply it temporarily:
|
|
24
18
|
|
|
25
19
|
```sh
|
|
26
|
-
|
|
27
|
-
omp plugin link ./omp-figma-remote-auth
|
|
20
|
+
npm exec --yes --package=bun -- omp plugin install omp-figma-remote-auth@latest
|
|
28
21
|
```
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
Start or restart OMP after installation:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
omp
|
|
27
|
+
```
|
|
31
28
|
|
|
32
|
-
|
|
29
|
+
### 2. Log in from the OMP input box
|
|
33
30
|
|
|
34
31
|
```text
|
|
35
|
-
/figma-remote-auth setup
|
|
36
32
|
/figma-remote-auth login
|
|
37
33
|
```
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
Your browser opens automatically. If it does not, click **点击这里授权 Figma** (“Click here to authorize Figma”) above the editor, or copy the complete short URL into your browser. The default app name on the consent page is **Codex**; see [authentication details](#configuration-and-authentication).
|
|
40
36
|
|
|
41
|
-
|
|
37
|
+
Approve access and wait for OMP to confirm that credentials were saved.
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
### 3. Check the connection in OMP
|
|
44
40
|
|
|
45
41
|
```text
|
|
46
42
|
/mcp reload
|
|
47
43
|
/mcp test figma
|
|
48
44
|
```
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
The server name is `figma`. Once connected, give OMP a Figma file or node URL you can access.
|
|
51
47
|
|
|
52
48
|
## Commands
|
|
53
49
|
|
|
54
|
-
|
|
50
|
+
These commands run **inside OMP's TUI**, not in your terminal.
|
|
55
51
|
|
|
56
52
|
| Command | Purpose |
|
|
57
53
|
| --- | --- |
|
|
58
|
-
| `/figma-remote-auth
|
|
59
|
-
| `/figma-remote-auth
|
|
60
|
-
| `/figma-remote-auth
|
|
61
|
-
| `/figma-remote-auth
|
|
62
|
-
| `/figma-remote-auth
|
|
63
|
-
| `/
|
|
54
|
+
| `/figma-remote-auth login` | Log in or reauthorize, handling necessary migration automatically. |
|
|
55
|
+
| `/figma-remote-auth status` | Show local credentials; does not test the connection. |
|
|
56
|
+
| `/figma-remote-auth cancel` | Cancel authorization and close the local entry and listener. |
|
|
57
|
+
| `/figma-remote-auth logout` | Clear this plugin's local credentials without revoking Figma authorization. |
|
|
58
|
+
| `/figma-remote-auth help` | Show full usage. |
|
|
59
|
+
| `/mcp list` | List MCP servers. |
|
|
64
60
|
|
|
65
|
-
|
|
61
|
+
Add `--no-browser` to open the short entry manually, `--port` to choose a port (random by default), or `--client-name` to change the app name (default `Codex`). See `help` for full usage.
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
/figma-remote-auth login --client-name Codex --port 19876
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
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.
|
|
63
|
+
Authorization waits up to **10 minutes**. Completion, cancellation, or exiting OMP releases the listener; closing the browser does not cancel the wait.
|
|
72
64
|
|
|
73
|
-
##
|
|
65
|
+
## Uninstall
|
|
74
66
|
|
|
75
|
-
|
|
67
|
+
Run this in your terminal, then restart OMP:
|
|
76
68
|
|
|
77
|
-
|
|
69
|
+
```sh
|
|
70
|
+
omp plugin uninstall omp-figma-remote-auth
|
|
71
|
+
```
|
|
78
72
|
|
|
79
|
-
|
|
73
|
+
Uninstalling removes the package's Figma registration and retains credentials for reinstalling. Independent user/project configuration is unaffected.
|
|
80
74
|
|
|
81
|
-
|
|
75
|
+
To clear credentials too, run `/figma-remote-auth logout` and `/mcp reload` in OMP before uninstalling. Revoke server-side authorization separately in your Figma account settings.
|
|
82
76
|
|
|
83
77
|
## Troubleshooting
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
Installing or linking the plugin does not log you in. Uninstalling it does not automatically clear credentials or remove the `figma` MCP configuration.
|
|
94
|
-
|
|
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.
|
|
96
|
-
|
|
97
|
-
Uninstall from the terminal:
|
|
79
|
+
| Problem | What to do |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| Plugin command not found | Check `omp plugin list` in your terminal, then restart OMP. |
|
|
82
|
+
| MCP not connected | Confirm the full package is installed, then run `/mcp reload` and `/mcp test figma`. |
|
|
83
|
+
| Callback fails or login keeps waiting | Cancel and retry with `/figma-remote-auth login --port 0`. The browser must reach `127.0.0.1` on the OMP machine; SSH/container forwarding is not configured automatically. |
|
|
84
|
+
| Config or credential conflict | Inspect the reported path and back up before editing. Custom config and credentials from other sources are not overwritten. |
|
|
85
|
+
| Authentication fails | Run `/figma-remote-auth login` again, then reload/test. `/mcp reauth figma` does not invoke this plugin. |
|
|
86
|
+
| Permission or quota error | Check the account, file access, and plan; see [Figma's limits](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/). |
|
|
98
87
|
|
|
99
|
-
|
|
100
|
-
omp plugin uninstall omp-figma-remote-auth
|
|
101
|
-
```
|
|
88
|
+
## Configuration and authentication
|
|
102
89
|
|
|
103
|
-
|
|
90
|
+
- **Config and credentials:** the package's `.mcp.json` provides Figma MCP; new installs do not write user MCP config. OMP stores and refreshes credentials. When using profiles, log in within the same profile.
|
|
91
|
+
- **App name:** `Codex` accommodates [Figma's client policy](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/#which-mcp-clients-are-supported). Each login registers an independent client without using Codex accounts or credentials.
|
|
92
|
+
- **Authorization entry:** a local short URL forwards the complete OAuth request. Long authorization URLs and tokens are never printed in the terminal.
|
|
104
93
|
|
|
105
|
-
##
|
|
94
|
+
## License and credits
|
|
106
95
|
|
|
107
|
-
|
|
96
|
+
[MIT](LICENSE). Adapted from [DianP/pi-figma-remote-auth](https://github.com/DianP/pi-figma-remote-auth), whose authentication approach references [sdaoudi/mcp-auth-helper](https://github.com/sdaoudi/mcp-auth-helper).
|
|
108
97
|
|
|
109
|
-
|
|
98
|
+
For source installation and testing, see the [development guide](https://github.com/picasuo/omp-figma-remote-auth/blob/main/DEVELOPMENT.md#english).
|
package/README.zh-CN.md
CHANGED
|
@@ -2,108 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
为 [Oh My Pi(OMP)](https://github.com/can1357/oh-my-pi) 接入 Figma 官方远程 MCP,提供浏览器登录,连接和令牌刷新由 OMP 负责。非官方插件,与 Figma、OpenAI 无隶属关系。
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 安装和授权
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- Git、拥有目标文件访问权限的 Figma 账号,以及与 OMP 运行在同一台机器上的浏览器,用于接收本地 OAuth 回调。无需 Figma 桌面客户端。
|
|
11
|
-
- 零运行时包依赖,无需 `pi-mcp-adapter`、`npm install` 或构建。开发测试需要 Node.js 22.6.0+ 和 npm。
|
|
9
|
+
需要 OMP 18.1.17+(已验证 18.1.17)、有文件访问权限的 Figma 账号,以及与 OMP 在同一台机器上的浏览器。无需 Figma Desktop 或手动编辑 MCP 配置。
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
在**终端(CLI)**中执行以下命令,从 npm 安装:
|
|
11
|
+
### 1. 安装:在终端执行
|
|
16
12
|
|
|
17
13
|
```sh
|
|
18
|
-
omp install omp-figma-remote-auth
|
|
14
|
+
omp plugin install omp-figma-remote-auth@latest
|
|
19
15
|
```
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
如需开发插件或从源码安装:
|
|
17
|
+
如果提示找不到 `bun`,可用 Node.js/npm 临时提供:
|
|
24
18
|
|
|
25
19
|
```sh
|
|
26
|
-
|
|
27
|
-
omp plugin link ./omp-figma-remote-auth
|
|
20
|
+
npm exec --yes --package=bun -- omp plugin install omp-figma-remote-auth@latest
|
|
28
21
|
```
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
安装后启动或重启 OMP:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
omp
|
|
27
|
+
```
|
|
31
28
|
|
|
32
|
-
|
|
29
|
+
### 2. 登录:在 OMP 输入框执行
|
|
33
30
|
|
|
34
31
|
```text
|
|
35
|
-
/figma-remote-auth setup
|
|
36
32
|
/figma-remote-auth login
|
|
37
33
|
```
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
浏览器会自动打开。未打开时,点击编辑器上方的 **点击这里授权 Figma**,或复制完整短地址到浏览器。默认授权应用名称为 **Codex**,原因见[认证说明](#配置与认证说明)。
|
|
40
36
|
|
|
41
|
-
|
|
37
|
+
确认授权,等待 OMP 提示凭据已保存。
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
### 3. 验证连接:在 OMP 输入框执行
|
|
44
40
|
|
|
45
41
|
```text
|
|
46
42
|
/mcp reload
|
|
47
43
|
/mcp test figma
|
|
48
44
|
```
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
服务名为 `figma`。连接成功后,即可向 OMP 提供你有权限访问的 Figma 文件或节点链接。
|
|
51
47
|
|
|
52
|
-
##
|
|
48
|
+
## 常用命令
|
|
53
49
|
|
|
54
|
-
|
|
50
|
+
以下均在 **OMP TUI** 中执行,不是终端命令。
|
|
55
51
|
|
|
56
52
|
| 命令 | 用途 |
|
|
57
53
|
| --- | --- |
|
|
58
|
-
| `/figma-remote-auth
|
|
59
|
-
| `/figma-remote-auth
|
|
60
|
-
| `/figma-remote-auth
|
|
61
|
-
| `/figma-remote-auth
|
|
62
|
-
| `/figma-remote-auth
|
|
63
|
-
| `/
|
|
54
|
+
| `/figma-remote-auth login` | 登录或重新授权,自动处理必要迁移。 |
|
|
55
|
+
| `/figma-remote-auth status` | 查看本地凭据,不测试连接。 |
|
|
56
|
+
| `/figma-remote-auth cancel` | 取消授权,关闭短入口和监听端口。 |
|
|
57
|
+
| `/figma-remote-auth logout` | 清除本插件的本地凭据,不撤销 Figma 端授权。 |
|
|
58
|
+
| `/figma-remote-auth help` | 查看完整用法。 |
|
|
59
|
+
| `/mcp list` | 查看 MCP 服务。 |
|
|
64
60
|
|
|
65
|
-
`
|
|
61
|
+
登录加 `--no-browser` 可手动打开短入口;`--port` 指定端口(默认随机),`--client-name` 修改应用名称(默认 `Codex`)。完整用法见 `help`。
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
/figma-remote-auth login --client-name Codex --port 19876
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
含空格的名称需加引号。Figma 可能拒绝其他客户端名称。登录会在 10 分钟后超时;执行 `cancel` 或退出 OMP 也会结束等待。setup、login 或 logout 后均应运行 `/mcp reload`,使 OMP 加载变更。
|
|
63
|
+
授权最多等待 **10 分钟**。流程结束、取消或退出 OMP 后释放监听端口;关闭浏览器不会取消等待。
|
|
72
64
|
|
|
73
|
-
##
|
|
65
|
+
## 卸载
|
|
74
66
|
|
|
75
|
-
|
|
67
|
+
在终端执行,然后重启 OMP:
|
|
76
68
|
|
|
77
|
-
|
|
69
|
+
```sh
|
|
70
|
+
omp plugin uninstall omp-figma-remote-auth
|
|
71
|
+
```
|
|
78
72
|
|
|
79
|
-
|
|
73
|
+
卸载会移除包提供的 Figma 注册,保留凭据供重装使用,独立的用户/项目配置不受影响。
|
|
80
74
|
|
|
81
|
-
|
|
75
|
+
如需清除凭据,卸载前在 OMP 中执行 `/figma-remote-auth logout` 和 `/mcp reload`。撤销 Figma 服务器端授权需到账号设置中操作。
|
|
82
76
|
|
|
83
77
|
## 常见问题
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
安装或链接插件不会自动登录;卸载插件也不会自动清除凭据或删除 `figma` MCP 配置。
|
|
94
|
-
|
|
95
|
-
如需清理凭据,请在**卸载前**进入每个曾授权的 profile,执行 `/figma-remote-auth logout` 和 `/mcp reload`。Logout 仅删除本地凭据,**不会撤销 Figma 服务器端授权**。如需撤销,请在 Figma 账号设置中移除对应应用的授权。
|
|
96
|
-
|
|
97
|
-
在终端执行卸载:
|
|
79
|
+
| 问题 | 处理方式 |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| 找不到插件命令 | 在终端用 `omp plugin list` 确认安装,再重启 OMP。 |
|
|
82
|
+
| MCP 未连接 | 确认完整插件包已安装,执行 `/mcp reload` 和 `/mcp test figma`。 |
|
|
83
|
+
| 回调失败或一直等待 | 取消后用 `/figma-remote-auth login --port 0` 重试。浏览器须能访问 OMP 所在机器的 `127.0.0.1`;SSH/容器不会自动配置端口转发。 |
|
|
84
|
+
| 配置或凭据冲突 | 检查提示路径,备份后再调整。插件不会覆盖自定义配置或其他来源的凭据。 |
|
|
85
|
+
| 认证失败 | 用 `/figma-remote-auth login` 重新登录,再 reload/test;`/mcp reauth figma` 不会调用本插件。 |
|
|
86
|
+
| 权限或额度不足 | 检查账号、文件权限及套餐,参见 [Figma 限额](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/)。 |
|
|
98
87
|
|
|
99
|
-
|
|
100
|
-
omp plugin uninstall omp-figma-remote-auth
|
|
101
|
-
```
|
|
88
|
+
## 配置与认证说明
|
|
102
89
|
|
|
103
|
-
|
|
90
|
+
- **配置与凭据:**包内 `.mcp.json` 提供 Figma 服务,新安装不写用户 MCP 配置。凭据由 OMP 保存和刷新;使用 profile 时,请在同一 profile 中登录。
|
|
91
|
+
- **授权应用名:**`Codex` 用于兼容 [Figma 客户端策略](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/#which-mcp-clients-are-supported)。每次登录注册独立客户端,不使用 Codex 的账号或凭据。
|
|
92
|
+
- **授权入口:**本机短链接转发完整 OAuth 请求,长授权 URL 和 token 不输出到终端。
|
|
104
93
|
|
|
105
|
-
##
|
|
94
|
+
## 许可证与致谢
|
|
106
95
|
|
|
107
|
-
|
|
96
|
+
[MIT](LICENSE)。改编自 [DianP/pi-figma-remote-auth](https://github.com/DianP/pi-figma-remote-auth),其认证思路参考了 [sdaoudi/mcp-auth-helper](https://github.com/sdaoudi/mcp-auth-helper)。
|
|
108
97
|
|
|
109
|
-
|
|
98
|
+
源码安装与测试请参阅[开发指南](https://github.com/picasuo/omp-figma-remote-auth/blob/main/DEVELOPMENT.md#简体中文)。
|
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 {
|
|
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 { migrateLegacyData } from "./src/migration.ts";
|
|
3
6
|
import { runOAuthFlow } from "./src/oauth.ts";
|
|
4
7
|
import type { FlowOptions, OAuthGrant } from "./src/oauth.ts";
|
|
5
|
-
import {
|
|
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: {
|
|
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
|
|
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") {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
85
|
+
const config = inspectLegacyConfig(agentDir, legacyId);
|
|
64
86
|
ctx.ui.notify([
|
|
65
87
|
`Figma Remote MCP: ${FIGMA_URL}`,
|
|
66
|
-
|
|
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; run /figma-remote-auth login" : config.kind === "conflict" ? `conflict at ${config.path}; resolve before login` : "absent"}.`,
|
|
67
91
|
credentialStatus(storage, credentialId),
|
|
68
92
|
`Authorization: ${active ? "operation in progress" : "idle"}.`,
|
|
69
93
|
].join("\n"), "info");
|
|
@@ -72,47 +96,87 @@ 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ctx.ui.notify("Figma MCP configured. Use /figma-remote-auth login to authorize, then run /mcp reload.", "info");
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
ctx.ui.setStatus?.(COMMAND_NAME, "Waiting for Figma authorization");
|
|
123
|
+
await migrateLegacyData(agentDir, storage, profile);
|
|
124
|
+
if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
|
|
125
|
+
statusShown = true;
|
|
126
|
+
tryUI(() => ctx.ui.setStatus?.(COMMAND_NAME, "Waiting for Figma authorization"));
|
|
94
127
|
const grant = await (dependencies.runOAuth ?? runOAuthFlow)({
|
|
95
128
|
clientName: command.clientName, port: command.port, signal: controller.signal,
|
|
96
|
-
|
|
97
|
-
|
|
129
|
+
onAuthorizationReady: async ({ startUrl, callbackUrl }) => {
|
|
130
|
+
if (controller.signal.aborted || cleaned || active !== current || entryShown) return;
|
|
131
|
+
if (!isAuthorizationEntry(startUrl, callbackUrl)) throw new UserError("Invalid local Figma authorization entry.");
|
|
132
|
+
entryShown = true;
|
|
133
|
+
const interactive = ctx.hasUI === true && ctx.mode === "tui";
|
|
134
|
+
const Markdown = pi.pi.Markdown;
|
|
135
|
+
const getMarkdownTheme = pi.pi.getMarkdownTheme;
|
|
136
|
+
let displayed = false;
|
|
137
|
+
if (interactive && ctx.ui.setWidget && Markdown && getMarkdownTheme) {
|
|
138
|
+
try {
|
|
139
|
+
widgetShown = true;
|
|
140
|
+
ctx.ui.setWidget(COMMAND_NAME, () => new Markdown(
|
|
141
|
+
'**[点击这里授权 Figma](<' + startUrl + '>)**\n\n浏览器未自动打开时,点击上方入口。取消:/figma-remote-auth cancel',
|
|
142
|
+
1, 0, getMarkdownTheme(),
|
|
143
|
+
), { placement: "aboveEditor" });
|
|
144
|
+
displayed = true;
|
|
145
|
+
} catch { /* Fall back to the validated plain entry below. */ }
|
|
146
|
+
}
|
|
147
|
+
if (controller.signal.aborted || cleaned || active !== current) return;
|
|
148
|
+
if (!displayed) {
|
|
149
|
+
notify(`Open this local entry in your browser to authorize Figma:\n${startUrl}\n\nUse /figma-remote-auth cancel to stop waiting.`, "info");
|
|
150
|
+
if (interactive) notify("This host does not support the Figma authorization widget. Use the local entry above.", "info");
|
|
151
|
+
}
|
|
152
|
+
if (!interactive || !command.openBrowser || controller.signal.aborted || cleaned || active !== current) return;
|
|
153
|
+
const opened = pi.exec && await openBrowser(startUrl, pi.exec.bind(pi), {
|
|
154
|
+
platform: dependencies.platform ?? process.platform, signal: controller.signal,
|
|
155
|
+
});
|
|
156
|
+
if (!opened && !controller.signal.aborted && !cleaned && active === current) {
|
|
157
|
+
notify("The browser could not be opened automatically. Use the Figma authorization entry above; authorization is still waiting.", "info");
|
|
158
|
+
}
|
|
98
159
|
},
|
|
99
160
|
});
|
|
100
161
|
if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
|
|
101
|
-
// Recheck
|
|
102
|
-
|
|
103
|
-
|
|
162
|
+
// Recheck the active profile, migration conflicts, and cancellation after the browser wait.
|
|
163
|
+
if (resolveActiveProfile() !== profile) throw new UserError("The active OMP profile changed during authorization; credentials were not saved.");
|
|
164
|
+
await migrateLegacyData(agentDir, storage, profile);
|
|
165
|
+
if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
|
|
104
166
|
await saveCredential(storage, credentialId, grant);
|
|
105
|
-
|
|
167
|
+
notify("Figma authorization saved. Run /mcp reload.", "info");
|
|
106
168
|
} catch (error) {
|
|
107
|
-
|
|
169
|
+
notify(formatError(error), "error");
|
|
108
170
|
} finally {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
operation.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
171
|
+
try {
|
|
172
|
+
if (cancel) ctx.signal?.removeEventListener("abort", cancel);
|
|
173
|
+
if (operation && active === operation) operation.cleanup();
|
|
174
|
+
} finally {
|
|
175
|
+
if (operation) {
|
|
176
|
+
operation.controller.abort();
|
|
177
|
+
if (active === operation) active = undefined;
|
|
178
|
+
finish?.();
|
|
179
|
+
}
|
|
116
180
|
}
|
|
117
181
|
}
|
|
118
182
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-figma-remote-auth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
@@ -17,8 +17,8 @@ export function formatError(error: unknown): string {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export type Command =
|
|
20
|
-
| { kind: "help" | "
|
|
21
|
-
| { kind: "login"; clientName: string; port: number };
|
|
20
|
+
| { kind: "help" | "status" | "logout" | "cancel" }
|
|
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[] {
|
|
@@ -58,24 +58,26 @@ function tokenize(input: string): string[] {
|
|
|
58
58
|
export function parseArgs(input: string): Command {
|
|
59
59
|
const [kind = "help", ...args] = tokenize(input);
|
|
60
60
|
if (kind !== "login") {
|
|
61
|
-
if (!["help", "
|
|
61
|
+
if (!["help", "status", "logout", "cancel"].includes(kind) || args.length) {
|
|
62
62
|
throw new UserError("Invalid command or arguments. Use /figma-remote-auth help.");
|
|
63
63
|
}
|
|
64
|
-
return { kind: kind as "help" | "
|
|
64
|
+
return { kind: kind as "help" | "status" | "logout" | "cancel" };
|
|
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;
|
|
70
|
-
const key = args[i];
|
|
71
|
-
|
|
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,22 @@ 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
|
-
"/figma-remote-auth
|
|
98
|
-
"/figma-remote-auth login [--client-name Codex] [--port 0]",
|
|
99
|
+
"/figma-remote-auth login [--client-name Codex] [--port 0] [--no-browser]",
|
|
99
100
|
"/figma-remote-auth status",
|
|
100
101
|
"/figma-remote-auth logout",
|
|
101
102
|
"/figma-remote-auth cancel",
|
|
102
103
|
"",
|
|
103
104
|
`Server: ${SERVER_NAME} (${FIGMA_URL})`,
|
|
104
|
-
"
|
|
105
|
-
"Login
|
|
106
|
-
"
|
|
105
|
+
"Figma MCP is managed by this plugin. Login migrates owned legacy configuration and credentials before authorization.",
|
|
106
|
+
"Login opens your browser in the interactive TUI and displays a highlighted local authorization entry.",
|
|
107
|
+
"Use --no-browser to open the short entry manually; RPC/print never open a browser automatically.",
|
|
108
|
+
"Use /figma-remote-auth cancel or exit OMP to stop waiting and close the local entry.",
|
|
109
|
+
"OMP handles MCP transport and token refresh. After login or logout, run /mcp reload.",
|
|
107
110
|
].join("\n");
|
|
108
111
|
}
|
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,
|
|
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
|
|
10
|
+
export interface LegacyConfigState { kind: "absent" | "owned" | "conflict"; path: string }
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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 (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
+
export function inspectLegacyConfig(agentDir: string, legacyId: string): LegacyConfigState {
|
|
65
|
+
checkDirectory(agentDir);
|
|
78
66
|
const path = join(agentDir, "mcp.json");
|
|
79
|
-
|
|
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
|
-
/**
|
|
88
|
-
export function
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
103
|
+
return true;
|
|
113
104
|
} catch (error) {
|
|
114
105
|
if (error instanceof UserError) throw error;
|
|
115
|
-
throw new UserError("Unable to
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
109
|
+
try {
|
|
110
|
+
if (temporary !== undefined) unlinkSync(temporary);
|
|
111
|
+
} finally {
|
|
112
|
+
try { closeSync(lock); } finally { unlinkSync(lockPath); }
|
|
113
|
+
}
|
|
121
114
|
}
|
|
122
115
|
}
|
package/src/migration.ts
ADDED
|
@@ -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 migrateLegacyData(
|
|
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 data 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
25
|
-
|
|
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,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
51
|
-
if (
|
|
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.";
|