@hangox/mg-cli 1.0.0
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/.eslintrc.cjs +26 -0
- package/CLAUDE.md +43 -0
- package/bin/mg-cli.js +4 -0
- package/package.json +51 -0
- package/src/cli/client.ts +266 -0
- package/src/cli/commands/execute-code.ts +59 -0
- package/src/cli/commands/export-image.ts +193 -0
- package/src/cli/commands/get-all-nodes.ts +81 -0
- package/src/cli/commands/get-all-pages.ts +118 -0
- package/src/cli/commands/get-node-by-id.ts +83 -0
- package/src/cli/commands/get-node-by-link.ts +105 -0
- package/src/cli/commands/server.ts +130 -0
- package/src/cli/index.ts +33 -0
- package/src/index.ts +9 -0
- package/src/server/connection-manager.ts +211 -0
- package/src/server/daemon-runner.ts +22 -0
- package/src/server/daemon.ts +211 -0
- package/src/server/index.ts +8 -0
- package/src/server/logger.ts +117 -0
- package/src/server/request-handler.ts +192 -0
- package/src/server/websocket-server.ts +297 -0
- package/src/shared/constants.ts +90 -0
- package/src/shared/errors.ts +131 -0
- package/src/shared/index.ts +8 -0
- package/src/shared/types.ts +227 -0
- package/src/shared/utils.ts +352 -0
- package/tests/unit/shared/constants.test.ts +66 -0
- package/tests/unit/shared/errors.test.ts +82 -0
- package/tests/unit/shared/utils.test.ts +208 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +33 -0
- package/vitest.config.ts +22 -0
package/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
root: true,
|
|
3
|
+
env: {
|
|
4
|
+
node: true,
|
|
5
|
+
es2022: true,
|
|
6
|
+
},
|
|
7
|
+
parser: '@typescript-eslint/parser',
|
|
8
|
+
parserOptions: {
|
|
9
|
+
ecmaVersion: 2022,
|
|
10
|
+
sourceType: 'module',
|
|
11
|
+
},
|
|
12
|
+
plugins: ['@typescript-eslint'],
|
|
13
|
+
extends: [
|
|
14
|
+
'eslint:recommended',
|
|
15
|
+
'plugin:@typescript-eslint/recommended',
|
|
16
|
+
],
|
|
17
|
+
rules: {
|
|
18
|
+
// 允许使用 console(CLI 工具需要输出)
|
|
19
|
+
'no-console': 'off',
|
|
20
|
+
// TypeScript 相关
|
|
21
|
+
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
22
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
23
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
24
|
+
},
|
|
25
|
+
ignorePatterns: ['dist', 'node_modules', '*.config.*'],
|
|
26
|
+
}
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# MG CLI
|
|
2
|
+
|
|
3
|
+
MasterGo Plugin 的命令行工具和 WebSocket 服务器。
|
|
4
|
+
|
|
5
|
+
## 文档索引
|
|
6
|
+
|
|
7
|
+
详细设计和开发指南请参阅 docs 目录:
|
|
8
|
+
|
|
9
|
+
| 文档 | 说明 |
|
|
10
|
+
|------|------|
|
|
11
|
+
| [00-概述.md](../docs/roles/00-概述.md) | 项目总览、系统架构、测试方案 |
|
|
12
|
+
| [01-MGPlugin-Server.md](../docs/roles/01-MGPlugin-Server.md) | Server 设计、守护进程、端口管理 |
|
|
13
|
+
| [02-MG-Cli.md](../docs/roles/02-MG-Cli.md) | **CLI 完整文档** - 命令详解、开发指南、测试方案 |
|
|
14
|
+
| [06-通用协议.md](../docs/roles/06-通用协议.md) | 消息协议、错误码、心跳机制 |
|
|
15
|
+
|
|
16
|
+
## 快速开始
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 安装依赖
|
|
20
|
+
pnpm install
|
|
21
|
+
|
|
22
|
+
# 开发模式
|
|
23
|
+
pnpm dev
|
|
24
|
+
|
|
25
|
+
# 构建
|
|
26
|
+
pnpm build
|
|
27
|
+
|
|
28
|
+
# 链接到全局
|
|
29
|
+
pnpm link --global
|
|
30
|
+
|
|
31
|
+
# 测试命令
|
|
32
|
+
mg-cli --help
|
|
33
|
+
mg-cli server start
|
|
34
|
+
mg-cli server status
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 一键检查
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm check # typecheck + lint + test + build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**编写代码后务必运行 `pnpm check` 确保所有测试通过**
|
package/bin/mg-cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hangox/mg-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MasterGo Plugin CLI 工具和 WebSocket 服务器",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"mg-cli": "./bin/mg-cli.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "tsup --watch",
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"lint": "eslint src --ext .ts",
|
|
17
|
+
"lint:fix": "eslint src --ext .ts --fix",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"test:unit": "vitest run tests/unit",
|
|
21
|
+
"test:integration": "vitest run tests/integration",
|
|
22
|
+
"test:coverage": "vitest run --coverage",
|
|
23
|
+
"test:ci": "vitest run --coverage --reporter=junit --outputFile=test-results.xml"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mastergo",
|
|
27
|
+
"cli",
|
|
28
|
+
"websocket",
|
|
29
|
+
"plugin"
|
|
30
|
+
],
|
|
31
|
+
"author": "",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"commander": "^12.1.0",
|
|
35
|
+
"ws": "^8.18.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.10.1",
|
|
39
|
+
"@types/ws": "^8.5.13",
|
|
40
|
+
"@typescript-eslint/eslint-plugin": "^8.17.0",
|
|
41
|
+
"@typescript-eslint/parser": "^8.17.0",
|
|
42
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
43
|
+
"eslint": "^8.57.1",
|
|
44
|
+
"tsup": "^8.3.5",
|
|
45
|
+
"typescript": "^5.7.2",
|
|
46
|
+
"vitest": "^2.1.8"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI 客户端
|
|
3
|
+
* 负责连接 Server 发送请求
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import WebSocket from 'ws'
|
|
7
|
+
import { readServerInfo, parseMgpLink } from '../shared/utils.js'
|
|
8
|
+
import {
|
|
9
|
+
PORT_RANGE_START,
|
|
10
|
+
PORT_RANGE_END,
|
|
11
|
+
PORT_SCAN_TIMEOUT,
|
|
12
|
+
REQUEST_TIMEOUT,
|
|
13
|
+
SERVER_START_TIMEOUT,
|
|
14
|
+
RETRY_INTERVALS,
|
|
15
|
+
MAX_RETRY_COUNT,
|
|
16
|
+
ConnectionType,
|
|
17
|
+
MessageType,
|
|
18
|
+
} from '../shared/constants.js'
|
|
19
|
+
import { ErrorCode, MGError, ErrorMessages } from '../shared/errors.js'
|
|
20
|
+
import { startServerDaemon } from '../server/daemon.js'
|
|
21
|
+
import { generateId } from '../shared/utils.js'
|
|
22
|
+
import type { RequestMessage, ResponseMessage } from '../shared/types.js'
|
|
23
|
+
|
|
24
|
+
/** 客户端选项 */
|
|
25
|
+
export interface ClientOptions {
|
|
26
|
+
/** 禁用自动启动 Server */
|
|
27
|
+
noAutoStart?: boolean
|
|
28
|
+
/** 禁用重试 */
|
|
29
|
+
noRetry?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* MG CLI 客户端
|
|
34
|
+
*/
|
|
35
|
+
export class MGClient {
|
|
36
|
+
private ws: WebSocket | null = null
|
|
37
|
+
private options: ClientOptions
|
|
38
|
+
|
|
39
|
+
constructor(options: ClientOptions = {}) {
|
|
40
|
+
this.options = options
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 连接到 Server
|
|
45
|
+
*/
|
|
46
|
+
async connect(): Promise<void> {
|
|
47
|
+
// 1. 尝试从文件读取端口
|
|
48
|
+
const serverInfo = readServerInfo()
|
|
49
|
+
if (serverInfo) {
|
|
50
|
+
try {
|
|
51
|
+
await this.tryConnect(serverInfo.port)
|
|
52
|
+
return
|
|
53
|
+
} catch {
|
|
54
|
+
// 文件信息失效,继续扫描
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. 端口扫描
|
|
59
|
+
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
|
60
|
+
try {
|
|
61
|
+
await this.tryConnect(port)
|
|
62
|
+
return
|
|
63
|
+
} catch {
|
|
64
|
+
// 继续尝试下一个端口
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. 自动启动 Server
|
|
69
|
+
if (!this.options.noAutoStart) {
|
|
70
|
+
console.log('Server 未运行,正在自动启动...')
|
|
71
|
+
try {
|
|
72
|
+
const info = await startServerDaemon()
|
|
73
|
+
console.log(`Server 已启动,端口: ${info.port}`)
|
|
74
|
+
|
|
75
|
+
// 等待 Server 就绪
|
|
76
|
+
await this.waitForServer(info.port)
|
|
77
|
+
return
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new MGError(
|
|
80
|
+
ErrorCode.SERVER_START_FAILED,
|
|
81
|
+
`自动启动 Server 失败: ${error instanceof Error ? error.message : error}`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new MGError(ErrorCode.CONNECTION_FAILED, ErrorMessages[ErrorCode.CONNECTION_FAILED])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 尝试连接指定端口
|
|
91
|
+
*/
|
|
92
|
+
private tryConnect(port: number): Promise<void> {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const ws = new WebSocket(`ws://localhost:${port}`)
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
ws.close()
|
|
97
|
+
reject(new Error('连接超时'))
|
|
98
|
+
}, PORT_SCAN_TIMEOUT)
|
|
99
|
+
|
|
100
|
+
ws.on('open', () => {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
this.ws = ws
|
|
103
|
+
// 注册为 Consumer
|
|
104
|
+
this.register()
|
|
105
|
+
resolve()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
ws.on('error', (error) => {
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
reject(error)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 等待 Server 就绪
|
|
117
|
+
*/
|
|
118
|
+
private async waitForServer(port: number): Promise<void> {
|
|
119
|
+
const startTime = Date.now()
|
|
120
|
+
const interval = 500
|
|
121
|
+
|
|
122
|
+
while (Date.now() - startTime < SERVER_START_TIMEOUT) {
|
|
123
|
+
try {
|
|
124
|
+
await this.tryConnect(port)
|
|
125
|
+
return
|
|
126
|
+
} catch {
|
|
127
|
+
await new Promise((r) => setTimeout(r, interval))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw new Error('等待 Server 启动超时')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 注册为 Consumer
|
|
136
|
+
*/
|
|
137
|
+
private register(): void {
|
|
138
|
+
if (!this.ws) return
|
|
139
|
+
|
|
140
|
+
const message = {
|
|
141
|
+
type: MessageType.REGISTER,
|
|
142
|
+
data: {
|
|
143
|
+
connectionType: ConnectionType.CONSUMER,
|
|
144
|
+
},
|
|
145
|
+
timestamp: Date.now(),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.ws.send(JSON.stringify(message))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 发送请求并等待响应
|
|
153
|
+
*/
|
|
154
|
+
async request<T = unknown>(
|
|
155
|
+
type: MessageType,
|
|
156
|
+
params?: Record<string, unknown>,
|
|
157
|
+
pageUrl?: string
|
|
158
|
+
): Promise<T> {
|
|
159
|
+
if (!this.ws) {
|
|
160
|
+
throw new MGError(ErrorCode.CONNECTION_FAILED, '未连接到 Server')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const requestId = generateId()
|
|
164
|
+
const message: RequestMessage = {
|
|
165
|
+
id: requestId,
|
|
166
|
+
type,
|
|
167
|
+
params,
|
|
168
|
+
pageUrl,
|
|
169
|
+
timestamp: Date.now(),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
reject(new MGError(ErrorCode.REQUEST_TIMEOUT, ErrorMessages[ErrorCode.REQUEST_TIMEOUT]))
|
|
175
|
+
}, REQUEST_TIMEOUT)
|
|
176
|
+
|
|
177
|
+
const messageHandler = (data: WebSocket.Data) => {
|
|
178
|
+
try {
|
|
179
|
+
const response = JSON.parse(data.toString()) as ResponseMessage
|
|
180
|
+
if (response.id === requestId) {
|
|
181
|
+
clearTimeout(timer)
|
|
182
|
+
this.ws?.off('message', messageHandler)
|
|
183
|
+
|
|
184
|
+
if (response.success) {
|
|
185
|
+
resolve(response.data as T)
|
|
186
|
+
} else {
|
|
187
|
+
const error = response.error
|
|
188
|
+
reject(
|
|
189
|
+
new MGError(
|
|
190
|
+
error?.code || ErrorCode.UNKNOWN_ERROR,
|
|
191
|
+
error?.message || '未知错误'
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// 忽略解析错误
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.ws!.on('message', messageHandler)
|
|
202
|
+
this.ws!.send(JSON.stringify(message))
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 带重试的请求
|
|
208
|
+
*/
|
|
209
|
+
async requestWithRetry<T = unknown>(
|
|
210
|
+
type: MessageType,
|
|
211
|
+
params?: Record<string, unknown>,
|
|
212
|
+
pageUrl?: string
|
|
213
|
+
): Promise<T> {
|
|
214
|
+
if (this.options.noRetry) {
|
|
215
|
+
return this.request<T>(type, params, pageUrl)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let lastError: Error | null = null
|
|
219
|
+
|
|
220
|
+
for (let attempt = 0; attempt <= MAX_RETRY_COUNT; attempt++) {
|
|
221
|
+
try {
|
|
222
|
+
return await this.request<T>(type, params, pageUrl)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
lastError = error instanceof Error ? error : new Error(String(error))
|
|
225
|
+
|
|
226
|
+
// 检查是否是可重试的错误
|
|
227
|
+
if (error instanceof MGError) {
|
|
228
|
+
const retryable = [ErrorCode.CONNECTION_LOST, ErrorCode.REQUEST_TIMEOUT]
|
|
229
|
+
if (!retryable.includes(error.code)) {
|
|
230
|
+
throw error
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 最后一次尝试不再等待
|
|
235
|
+
if (attempt < MAX_RETRY_COUNT) {
|
|
236
|
+
const delay = RETRY_INTERVALS[attempt] || RETRY_INTERVALS[RETRY_INTERVALS.length - 1]
|
|
237
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
238
|
+
|
|
239
|
+
// 重新连接
|
|
240
|
+
try {
|
|
241
|
+
await this.connect()
|
|
242
|
+
} catch {
|
|
243
|
+
// 重连失败,继续重试
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
throw lastError || new MGError(ErrorCode.UNKNOWN_ERROR, '请求失败')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 关闭连接
|
|
254
|
+
*/
|
|
255
|
+
close(): void {
|
|
256
|
+
if (this.ws) {
|
|
257
|
+
this.ws.close()
|
|
258
|
+
this.ws = null
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 解析 mgp:// 链接
|
|
265
|
+
*/
|
|
266
|
+
export { parseMgpLink }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execute_code 命令
|
|
3
|
+
* 在 MasterGo 页面执行自定义 JavaScript 代码
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from 'commander'
|
|
7
|
+
import { MessageType } from '../../shared/constants.js'
|
|
8
|
+
import { MGClient } from '../client.js'
|
|
9
|
+
|
|
10
|
+
interface ExecuteCodeOptions {
|
|
11
|
+
noAutoStart?: boolean
|
|
12
|
+
noRetry?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 创建 execute_code 命令
|
|
17
|
+
*/
|
|
18
|
+
export function createExecuteCodeCommand(): Command {
|
|
19
|
+
return new Command('execute_code')
|
|
20
|
+
.description('在 MasterGo 页面执行自定义 JavaScript 代码。通过 mg 变量访问 MasterGo API,结果会被 JSON 序列化返回')
|
|
21
|
+
.argument('<code>', '要执行的代码。可使用 mg 变量,如 mg.currentPage.name、mg.currentPage.selection')
|
|
22
|
+
.option('--no-auto-start', '禁用自动启动 Server')
|
|
23
|
+
.option('--no-retry', '禁用自动重试')
|
|
24
|
+
.action(async (code: string, options: ExecuteCodeOptions) => {
|
|
25
|
+
await handleExecuteCode(code, options)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 处理 execute_code 命令
|
|
31
|
+
*/
|
|
32
|
+
async function handleExecuteCode(code: string, options: ExecuteCodeOptions): Promise<void> {
|
|
33
|
+
const client = new MGClient({
|
|
34
|
+
noAutoStart: options.noAutoStart,
|
|
35
|
+
noRetry: options.noRetry,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// 连接 Server
|
|
40
|
+
await client.connect()
|
|
41
|
+
|
|
42
|
+
// 发送请求
|
|
43
|
+
const result = await client.requestWithRetry<unknown>(MessageType.EXECUTE_CODE, { code })
|
|
44
|
+
|
|
45
|
+
// 输出结果
|
|
46
|
+
if (result === null || result === undefined) {
|
|
47
|
+
console.log('执行完成(无返回值)')
|
|
48
|
+
} else if (typeof result === 'object') {
|
|
49
|
+
console.log(JSON.stringify(result, null, 2))
|
|
50
|
+
} else {
|
|
51
|
+
console.log(result)
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`错误: ${error instanceof Error ? error.message : error}`)
|
|
55
|
+
process.exit(1)
|
|
56
|
+
} finally {
|
|
57
|
+
client.close()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* export_image 命令
|
|
3
|
+
* 导出 MasterGo 节点为图片文件
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from 'commander'
|
|
7
|
+
import { writeFileSync } from 'node:fs'
|
|
8
|
+
import { resolve, dirname, extname } from 'node:path'
|
|
9
|
+
import { mkdirSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { MessageType } from '../../shared/constants.js'
|
|
12
|
+
import { MGClient, parseMgpLink } from '../client.js'
|
|
13
|
+
import { MGError } from '../../shared/errors.js'
|
|
14
|
+
import type { ExportImageParams } from '../../shared/types.js'
|
|
15
|
+
|
|
16
|
+
type ImageFormat = 'PNG' | 'JPG' | 'SVG' | 'PDF' | 'WEBP'
|
|
17
|
+
|
|
18
|
+
interface ExportImageOptions {
|
|
19
|
+
output?: string
|
|
20
|
+
link?: string
|
|
21
|
+
format?: string
|
|
22
|
+
scale?: string
|
|
23
|
+
width?: string
|
|
24
|
+
height?: string
|
|
25
|
+
useAbsoluteBounds?: boolean
|
|
26
|
+
useRenderBounds?: boolean
|
|
27
|
+
noAutoStart?: boolean
|
|
28
|
+
noRetry?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 导出响应 */
|
|
32
|
+
interface ExportResponse {
|
|
33
|
+
/** Base64 编码的图片数据 */
|
|
34
|
+
data: string
|
|
35
|
+
/** MIME 类型 */
|
|
36
|
+
mimeType: string
|
|
37
|
+
/** 文件名建议 */
|
|
38
|
+
filename?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 创建 export_image 命令
|
|
43
|
+
*/
|
|
44
|
+
export function createExportImageCommand(): Command {
|
|
45
|
+
return new Command('export_image')
|
|
46
|
+
.description('导出 MasterGo 节点为图片文件。强烈建议指定 --output,否则保存到临时目录可能被系统清理')
|
|
47
|
+
.option('--output <path>', '输出文件路径。强烈建议指定,否则保存到系统临时目录可能被清理')
|
|
48
|
+
.option('--link <mgp-link>', 'mgp:// 协议链接。不指定则导出当前选中节点')
|
|
49
|
+
.option('--format <type>', '导出格式:PNG(无损透明)、JPG(有损)、SVG(矢量)、PDF、WEBP', 'PNG')
|
|
50
|
+
.option('--scale <number>', '缩放倍率(如 1、2、3)。与 width/height 互斥')
|
|
51
|
+
.option('--width <number>', '固定宽度(像素)。与 scale/height 互斥')
|
|
52
|
+
.option('--height <number>', '固定高度(像素)。与 scale/width 互斥')
|
|
53
|
+
.option('--useAbsoluteBounds', '使用完整尺寸。true: 包含被裁剪部分,false: 只导出可见区域', false)
|
|
54
|
+
.option('--no-use-render-bounds', '不包含特效和外描边。默认包含阴影、外描边等')
|
|
55
|
+
.option('--no-auto-start', '禁用自动启动 Server')
|
|
56
|
+
.option('--no-retry', '禁用自动重试')
|
|
57
|
+
.action(async (options: ExportImageOptions) => {
|
|
58
|
+
await handleExportImage(options)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 处理 export_image 命令
|
|
64
|
+
*/
|
|
65
|
+
async function handleExportImage(options: ExportImageOptions): Promise<void> {
|
|
66
|
+
// 验证格式
|
|
67
|
+
const format = (options.format?.toUpperCase() || 'PNG') as ImageFormat
|
|
68
|
+
const validFormats: ImageFormat[] = ['PNG', 'JPG', 'SVG', 'PDF', 'WEBP']
|
|
69
|
+
if (!validFormats.includes(format)) {
|
|
70
|
+
console.error(`错误: 不支持的格式 "${options.format}"`)
|
|
71
|
+
console.error(`支持的格式: ${validFormats.join(', ')}`)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 验证参数互斥
|
|
76
|
+
const sizeParams = [options.scale, options.width, options.height].filter(Boolean)
|
|
77
|
+
if (sizeParams.length > 1) {
|
|
78
|
+
console.error('错误: scale、width、height 三者互斥,只能指定其中一个')
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 解析 link 参数
|
|
83
|
+
let pageUrl: string | undefined
|
|
84
|
+
let nodeId: string | undefined
|
|
85
|
+
|
|
86
|
+
if (options.link) {
|
|
87
|
+
const linkInfo = parseMgpLink(options.link)
|
|
88
|
+
if (!linkInfo) {
|
|
89
|
+
console.error(`错误: 无效的 mgp:// 链接格式: ${options.link}`)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
pageUrl = linkInfo.pageUrl
|
|
93
|
+
nodeId = linkInfo.nodeId
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const client = new MGClient({
|
|
97
|
+
noAutoStart: options.noAutoStart,
|
|
98
|
+
noRetry: options.noRetry,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// 连接 Server
|
|
103
|
+
await client.connect()
|
|
104
|
+
|
|
105
|
+
// 构建请求参数
|
|
106
|
+
const params: ExportImageParams = {
|
|
107
|
+
format,
|
|
108
|
+
useAbsoluteBounds: options.useAbsoluteBounds || false,
|
|
109
|
+
useRenderBounds: options.useRenderBounds !== false,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (nodeId) {
|
|
113
|
+
params.nodeId = nodeId
|
|
114
|
+
}
|
|
115
|
+
if (options.scale) {
|
|
116
|
+
params.scale = parseFloat(options.scale)
|
|
117
|
+
}
|
|
118
|
+
if (options.width) {
|
|
119
|
+
params.width = parseInt(options.width, 10)
|
|
120
|
+
}
|
|
121
|
+
if (options.height) {
|
|
122
|
+
params.height = parseInt(options.height, 10)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 发送请求(传入 pageUrl 以指定目标页面)
|
|
126
|
+
const response = await client.requestWithRetry<ExportResponse>(
|
|
127
|
+
MessageType.EXPORT_IMAGE,
|
|
128
|
+
params as Record<string, unknown>,
|
|
129
|
+
pageUrl
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// 确定输出路径
|
|
133
|
+
const ext = getExtension(format)
|
|
134
|
+
let outputPath: string
|
|
135
|
+
if (options.output) {
|
|
136
|
+
outputPath = resolve(options.output)
|
|
137
|
+
// 如果没有扩展名,添加扩展名
|
|
138
|
+
if (!extname(outputPath)) {
|
|
139
|
+
outputPath = `${outputPath}${ext}`
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// 使用临时目录
|
|
143
|
+
const filename = response.filename || `export_${Date.now()}${ext}`
|
|
144
|
+
outputPath = resolve(tmpdir(), filename)
|
|
145
|
+
console.log('警告: 未指定 --output,文件将保存到临时目录,可能会被系统清理')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 确保目录存在
|
|
149
|
+
const outputDir = dirname(outputPath)
|
|
150
|
+
mkdirSync(outputDir, { recursive: true })
|
|
151
|
+
|
|
152
|
+
// 解码 Base64 并保存
|
|
153
|
+
const buffer = Buffer.from(response.data, 'base64')
|
|
154
|
+
writeFileSync(outputPath, buffer)
|
|
155
|
+
|
|
156
|
+
// 输出结果
|
|
157
|
+
const sizeKB = (buffer.length / 1024).toFixed(2)
|
|
158
|
+
console.log(`文件路径: ${outputPath}`)
|
|
159
|
+
if (options.link) {
|
|
160
|
+
console.log(`Link: ${options.link}`)
|
|
161
|
+
}
|
|
162
|
+
if (nodeId) {
|
|
163
|
+
console.log(`节点 ID: ${nodeId}`)
|
|
164
|
+
} else {
|
|
165
|
+
console.log('节点 ID: (选中的节点)')
|
|
166
|
+
}
|
|
167
|
+
console.log(`导出格式: ${format}`)
|
|
168
|
+
console.log(`文件大小: ${buffer.length.toLocaleString()} 字节 (约 ${sizeKB} KB)`)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof MGError) {
|
|
171
|
+
console.error(`错误 [${error.code}]: ${error.message}`)
|
|
172
|
+
} else {
|
|
173
|
+
console.error(`错误: ${error instanceof Error ? error.message : error}`)
|
|
174
|
+
}
|
|
175
|
+
process.exit(1)
|
|
176
|
+
} finally {
|
|
177
|
+
client.close()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 获取格式对应的文件扩展名
|
|
183
|
+
*/
|
|
184
|
+
function getExtension(format: ImageFormat): string {
|
|
185
|
+
const extensions: Record<ImageFormat, string> = {
|
|
186
|
+
PNG: '.png',
|
|
187
|
+
JPG: '.jpg',
|
|
188
|
+
SVG: '.svg',
|
|
189
|
+
PDF: '.pdf',
|
|
190
|
+
WEBP: '.webp',
|
|
191
|
+
}
|
|
192
|
+
return extensions[format]
|
|
193
|
+
}
|