@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 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
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ // CLI 入口文件,加载编译后的 CLI 模块
4
+ import '../dist/cli.js'
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
+ }