@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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * MG Plugin 类型定义
3
+ */
4
+
5
+ import { ConnectionType, MessageType } from './constants.js'
6
+ import { ErrorCode } from './errors.js'
7
+
8
+ // ==================== Server 信息 ====================
9
+
10
+ /** Server 运行信息 */
11
+ export interface ServerInfo {
12
+ /** 监听端口 */
13
+ port: number
14
+ /** 进程 ID */
15
+ pid: number
16
+ /** 启动时间 (ISO 8601) */
17
+ startedAt: string
18
+ }
19
+
20
+ // ==================== WebSocket 消息 ====================
21
+
22
+ /** 基础消息结构 */
23
+ export interface BaseMessage {
24
+ /** 消息 ID */
25
+ id?: string
26
+ /** 消息类型 */
27
+ type: MessageType | string
28
+ /** 时间戳 */
29
+ timestamp?: number
30
+ }
31
+
32
+ /** 请求消息 */
33
+ export interface RequestMessage extends BaseMessage {
34
+ /** 目标页面 URL(标准化后的) */
35
+ pageUrl?: string
36
+ /** 请求参数 */
37
+ params?: Record<string, unknown>
38
+ }
39
+
40
+ /** 响应消息 */
41
+ export interface ResponseMessage extends BaseMessage {
42
+ /** 对应请求的 ID */
43
+ id: string
44
+ /** 是否成功 */
45
+ success: boolean
46
+ /** 成功时的数据 */
47
+ data?: unknown
48
+ /** 失败时的错误信息 */
49
+ error?: ErrorInfo | null
50
+ }
51
+
52
+ /** 错误信息 */
53
+ export interface ErrorInfo {
54
+ /** 错误码 */
55
+ code: ErrorCode
56
+ /** 错误名称 */
57
+ name: string
58
+ /** 错误消息 */
59
+ message: string
60
+ /** 额外详情 */
61
+ details?: Record<string, unknown>
62
+ }
63
+
64
+ /** 注册消息 */
65
+ export interface RegisterMessage extends BaseMessage {
66
+ type: 'register'
67
+ data: {
68
+ /** 连接类型 */
69
+ connectionType: ConnectionType
70
+ /** 页面 URL(Provider 必需) */
71
+ pageUrl?: string
72
+ /** 页面唯一 ID */
73
+ pageId?: string
74
+ }
75
+ }
76
+
77
+ /** 心跳消息 */
78
+ export interface PingMessage extends BaseMessage {
79
+ type: 'ping'
80
+ timestamp: number
81
+ }
82
+
83
+ export interface PongMessage extends BaseMessage {
84
+ type: 'pong'
85
+ timestamp: number
86
+ }
87
+
88
+ // ==================== 连接管理 ====================
89
+
90
+ /** 连接信息 */
91
+ export interface ConnectionInfo {
92
+ /** 连接 ID */
93
+ id: string
94
+ /** 连接类型 */
95
+ type: ConnectionType
96
+ /** 页面 URL(仅 Provider) */
97
+ pageUrl?: string
98
+ /** 页面 ID(仅 Provider) */
99
+ pageId?: string
100
+ /** 连接时间 */
101
+ connectedAt: Date
102
+ /** 最后活跃时间 */
103
+ lastActiveAt: Date
104
+ }
105
+
106
+ // ==================== 节点相关 ====================
107
+
108
+ /** 节点信息(简化版) */
109
+ export interface NodeInfo {
110
+ /** 节点 ID */
111
+ id: string
112
+ /** 节点名称 */
113
+ name: string
114
+ /** 节点类型 */
115
+ type: string
116
+ /** 是否可见 */
117
+ visible: boolean
118
+ /** X 坐标 */
119
+ x?: number
120
+ /** Y 坐标 */
121
+ y?: number
122
+ /** 宽度 */
123
+ width?: number
124
+ /** 高度 */
125
+ height?: number
126
+ /** 子节点 */
127
+ children?: NodeInfo[]
128
+ /** 其他属性 */
129
+ [key: string]: unknown
130
+ }
131
+
132
+ /** 获取节点参数 */
133
+ export interface GetNodeParams {
134
+ /** 节点 ID */
135
+ nodeId: string
136
+ /** 遍历深度 */
137
+ maxDepth?: number
138
+ /** 是否包含不可见节点 */
139
+ includeInvisible?: boolean
140
+ /** 索引签名 */
141
+ [key: string]: unknown
142
+ }
143
+
144
+ /** 获取所有节点参数 */
145
+ export interface GetAllNodesParams {
146
+ /** 遍历深度 */
147
+ maxDepth?: number
148
+ /** 是否包含不可见节点 */
149
+ includeInvisible?: boolean
150
+ /** 索引签名 */
151
+ [key: string]: unknown
152
+ }
153
+
154
+ /** 导出图片参数 */
155
+ export interface ExportImageParams {
156
+ /** 节点 ID(可选,默认第一个选中节点) */
157
+ nodeId?: string
158
+ /** 导出格式 */
159
+ format?: 'PNG' | 'JPG' | 'SVG' | 'PDF' | 'WEBP'
160
+ /** 缩放倍率 */
161
+ scale?: number
162
+ /** 固定宽度 */
163
+ width?: number
164
+ /** 固定高度 */
165
+ height?: number
166
+ /** 是否使用完整尺寸 */
167
+ useAbsoluteBounds?: boolean
168
+ /** 是否包含特效和外描边 */
169
+ useRenderBounds?: boolean
170
+ }
171
+
172
+ // ==================== CLI 相关 ====================
173
+
174
+ /** CLI 输出格式化函数类型 */
175
+ export type OutputFormatter = (data: Record<string, unknown>) => void
176
+
177
+ /** CLI 命令选项 */
178
+ export interface CliOptions {
179
+ /** 输出文件路径 */
180
+ output?: string
181
+ /** 遍历深度 */
182
+ maxDepth?: number
183
+ /** 包含不可见节点 */
184
+ includeInvisible?: boolean
185
+ /** 禁用自动重试 */
186
+ noRetry?: boolean
187
+ /** 禁用自动启动 Server */
188
+ noAutoStart?: boolean
189
+ /** Server 端口 */
190
+ port?: number
191
+ /** 前台模式运行 */
192
+ foreground?: boolean
193
+ }
194
+
195
+ // ==================== 页面信息 ====================
196
+
197
+ /** RGBA 颜色 */
198
+ export interface RGBA {
199
+ r: number
200
+ g: number
201
+ b: number
202
+ a: number
203
+ }
204
+
205
+ /** 单个页面信息 */
206
+ export interface MgPageInfo {
207
+ /** 页面 ID */
208
+ id: string
209
+ /** 页面名称 */
210
+ name: string
211
+ /** 页面背景色 */
212
+ bgColor?: RGBA
213
+ /** 直接子节点数量 */
214
+ childCount: number
215
+ }
216
+
217
+ /** 所有页面信息 */
218
+ export interface AllPagesInfo {
219
+ /** 文档 ID */
220
+ documentId: string
221
+ /** 文档名称 */
222
+ documentName: string
223
+ /** 页面列表 */
224
+ pages: MgPageInfo[]
225
+ /** 页面总数 */
226
+ totalCount: number
227
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * MG Plugin 工具函数
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
6
+ import { dirname, resolve, isAbsolute } from 'node:path'
7
+ import { CONFIG_DIR, SERVER_INFO_FILE, LOG_DIR } from './constants.js'
8
+ import type { ServerInfo } from './types.js'
9
+
10
+ // ==================== 文件操作 ====================
11
+
12
+ /**
13
+ * 确保目录存在
14
+ */
15
+ export function ensureDir(dir: string): void {
16
+ if (!existsSync(dir)) {
17
+ mkdirSync(dir, { recursive: true })
18
+ }
19
+ }
20
+
21
+ /**
22
+ * 确保配置目录存在
23
+ */
24
+ export function ensureConfigDir(): void {
25
+ ensureDir(CONFIG_DIR)
26
+ ensureDir(LOG_DIR)
27
+ }
28
+
29
+ /**
30
+ * 读取 Server 信息文件
31
+ */
32
+ export function readServerInfo(): ServerInfo | null {
33
+ try {
34
+ if (!existsSync(SERVER_INFO_FILE)) {
35
+ return null
36
+ }
37
+ const content = readFileSync(SERVER_INFO_FILE, 'utf-8')
38
+ return JSON.parse(content) as ServerInfo
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 写入 Server 信息文件
46
+ */
47
+ export function writeServerInfo(info: ServerInfo): void {
48
+ ensureConfigDir()
49
+ writeFileSync(SERVER_INFO_FILE, JSON.stringify(info, null, 2), 'utf-8')
50
+ }
51
+
52
+ /**
53
+ * 删除 Server 信息文件
54
+ */
55
+ export function deleteServerInfo(): void {
56
+ try {
57
+ if (existsSync(SERVER_INFO_FILE)) {
58
+ unlinkSync(SERVER_INFO_FILE)
59
+ }
60
+ } catch {
61
+ // 忽略删除错误
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 解析输出路径(支持相对路径和绝对路径)
67
+ */
68
+ export function resolveOutputPath(outputPath: string): string {
69
+ if (isAbsolute(outputPath)) {
70
+ return outputPath
71
+ }
72
+ return resolve(process.cwd(), outputPath)
73
+ }
74
+
75
+ /**
76
+ * 确保输出路径的目录存在
77
+ */
78
+ export function ensureOutputDir(outputPath: string): void {
79
+ const dir = dirname(outputPath)
80
+ ensureDir(dir)
81
+ }
82
+
83
+ // ==================== 进程管理 ====================
84
+
85
+ /**
86
+ * 检查进程是否存在
87
+ */
88
+ export function isProcessRunning(pid: number): boolean {
89
+ try {
90
+ // 发送信号 0 不会终止进程,但如果进程不存在会抛出错误
91
+ process.kill(pid, 0)
92
+ return true
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ /**
99
+ * 终止进程
100
+ */
101
+ export function killProcess(pid: number): boolean {
102
+ try {
103
+ process.kill(pid, 'SIGTERM')
104
+ return true
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ // ==================== URL 处理 ====================
111
+
112
+ /**
113
+ * 标准化页面 URL
114
+ *
115
+ * 输入: https://mastergo.netease.com/file/174135798361888?fileOpenFrom=home&page_id=0%3A8347
116
+ * 输出: mastergo.netease.com/file/174135798361888
117
+ */
118
+ export function normalizePageUrl(url: string): string {
119
+ try {
120
+ const urlObj = new URL(url)
121
+ // 返回 host + pathname,去掉 https:// 前缀和查询参数
122
+ return urlObj.host + urlObj.pathname
123
+ } catch {
124
+ // 如果已经是标准化的格式,直接返回
125
+ return url
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 检查是否是设计页面 URL
131
+ */
132
+ export function isDesignPageUrl(url: string): boolean {
133
+ // 匹配 /file/数字 格式
134
+ return /\/file\/\d+/.test(url)
135
+ }
136
+
137
+ /**
138
+ * 解析 mgp:// 链接
139
+ *
140
+ * 支持的格式 (queryParams 格式,nodeId 需要 URL 编码):
141
+ * - mgp://mastergo.netease.com/file/174135798361888?nodeId=123%3A456 (单个节点)
142
+ * - mgp://mastergo.netease.com/file/174135798361888?nodeId=0%3A8633&nodePath=314%3A13190%2F0%3A8633 (带父节点路径)
143
+ *
144
+ * 输出: { pageUrl: 'mastergo.netease.com/file/174135798361888', nodeId: '0:8633', nodePath: ['314:13190', '0:8633'] }
145
+ */
146
+ export function parseMgpLink(link: string): { pageUrl: string; nodeId: string; nodePath?: string[] } | null {
147
+ // 检查是否是 mgp:// 协议
148
+ if (!link.startsWith('mgp://')) {
149
+ return null
150
+ }
151
+
152
+ try {
153
+ // 移除 mgp:// 前缀,构造为可解析的 URL
154
+ const urlPart = link.slice(6) // 移除 'mgp://'
155
+ const questionMarkIndex = urlPart.indexOf('?')
156
+
157
+ if (questionMarkIndex === -1) {
158
+ // 没有查询参数,无效格式
159
+ return null
160
+ }
161
+
162
+ const pageUrl = urlPart.slice(0, questionMarkIndex)
163
+ const queryString = urlPart.slice(questionMarkIndex + 1)
164
+
165
+ // 解析查询参数
166
+ const params = new URLSearchParams(queryString)
167
+ const encodedNodeId = params.get('nodeId')
168
+
169
+ if (!encodedNodeId) {
170
+ return null
171
+ }
172
+
173
+ // 解码 nodeId
174
+ const nodeId = decodeURIComponent(encodedNodeId)
175
+
176
+ // 验证 nodeId 格式:支持单个节点 (数字:数字) 或带路径的格式 (数字:数字/数字:数字/...)
177
+ if (!/^(\d+:\d+)(\/\d+:\d+)*$/.test(nodeId)) {
178
+ return null
179
+ }
180
+
181
+ // 解析可选的 nodePath
182
+ const encodedNodePath = params.get('nodePath')
183
+ let nodePath: string[] | undefined
184
+
185
+ if (encodedNodePath) {
186
+ const decodedNodePath = decodeURIComponent(encodedNodePath)
187
+ nodePath = decodedNodePath.split('/').filter(Boolean)
188
+ // 验证每个路径段的格式
189
+ if (!nodePath.every(segment => /^\d+:\d+$/.test(segment))) {
190
+ return null
191
+ }
192
+ }
193
+
194
+ return {
195
+ pageUrl,
196
+ nodeId,
197
+ nodePath,
198
+ }
199
+ } catch {
200
+ return null
201
+ }
202
+ }
203
+
204
+ /**
205
+ * 生成 mgp:// 链接
206
+ *
207
+ * @param pageUrl 页面 URL(会被标准化)
208
+ * @param nodeId 节点 ID(会被 URL 编码)
209
+ * @param nodePath 可选的父节点路径(会被 URL 编码)
210
+ */
211
+ export function generateMgpLink(pageUrl: string, nodeId: string, nodePath?: string[]): string {
212
+ const normalizedUrl = normalizePageUrl(pageUrl)
213
+ const encodedNodeId = encodeURIComponent(nodeId)
214
+
215
+ let link = `mgp://${normalizedUrl}?nodeId=${encodedNodeId}`
216
+
217
+ if (nodePath && nodePath.length > 0) {
218
+ const encodedNodePath = encodeURIComponent(nodePath.join('/'))
219
+ link += `&nodePath=${encodedNodePath}`
220
+ }
221
+
222
+ return link
223
+ }
224
+
225
+ // ==================== 输出格式化 ====================
226
+
227
+ /**
228
+ * 格式化文件大小
229
+ */
230
+ export function formatFileSize(bytes: number): string {
231
+ if (bytes < 1024) {
232
+ return `${bytes} 字节`
233
+ }
234
+ const kb = bytes / 1024
235
+ if (kb < 1024) {
236
+ return `${kb.toFixed(2)} KB`
237
+ }
238
+ const mb = kb / 1024
239
+ return `${mb.toFixed(2)} MB`
240
+ }
241
+
242
+ /**
243
+ * 格式化时间间隔
244
+ */
245
+ export function formatDuration(ms: number): string {
246
+ const seconds = Math.floor(ms / 1000)
247
+ const minutes = Math.floor(seconds / 60)
248
+ const hours = Math.floor(minutes / 60)
249
+ const days = Math.floor(hours / 24)
250
+
251
+ if (days > 0) {
252
+ return `${days} 天 ${hours % 24} 小时`
253
+ }
254
+ if (hours > 0) {
255
+ return `${hours} 小时 ${minutes % 60} 分钟`
256
+ }
257
+ if (minutes > 0) {
258
+ return `${minutes} 分钟 ${seconds % 60} 秒`
259
+ }
260
+ return `${seconds} 秒`
261
+ }
262
+
263
+ /**
264
+ * 生成唯一 ID
265
+ */
266
+ export function generateId(): string {
267
+ return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
268
+ }
269
+
270
+ /**
271
+ * 获取当前时间的 ISO 格式字符串
272
+ */
273
+ export function getCurrentISOTime(): string {
274
+ return new Date().toISOString()
275
+ }
276
+
277
+ /**
278
+ * 格式化日期时间用于日志
279
+ */
280
+ export function formatLogTime(date: Date = new Date()): string {
281
+ const year = date.getFullYear()
282
+ const month = String(date.getMonth() + 1).padStart(2, '0')
283
+ const day = String(date.getDate()).padStart(2, '0')
284
+ const hours = String(date.getHours()).padStart(2, '0')
285
+ const minutes = String(date.getMinutes()).padStart(2, '0')
286
+ const seconds = String(date.getSeconds()).padStart(2, '0')
287
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
288
+ }
289
+
290
+ // ==================== FileId 提取 ====================
291
+
292
+ /**
293
+ * 从完整 URL 提取 fileId
294
+ *
295
+ * 支持格式:
296
+ * - https://mastergo.netease.com/file/174875497054651?page_id=321%3A11979
297
+ * - mastergo.netease.com/file/174875497054651
298
+ *
299
+ * @returns fileId 或 null
300
+ */
301
+ export function extractFileIdFromUrl(url: string): string | null {
302
+ // 匹配 /file/数字 格式
303
+ const match = url.match(/\/file\/(\d+)/)
304
+ return match ? match[1] : null
305
+ }
306
+
307
+ /**
308
+ * 从 mgp:// 链接提取 fileId
309
+ *
310
+ * 支持格式:
311
+ * - mgp://mastergo.netease.com/file/174875497054651?nodeId=xxx
312
+ *
313
+ * @returns fileId 或 null
314
+ */
315
+ export function extractFileIdFromMgpLink(link: string): string | null {
316
+ if (!link.startsWith('mgp://')) {
317
+ return null
318
+ }
319
+ return extractFileIdFromUrl(link)
320
+ }
321
+
322
+ /**
323
+ * 统一处理输入,提取 fileId
324
+ *
325
+ * 支持三种输入格式:
326
+ * 1. 完整 URL: https://mastergo.netease.com/file/174875497054651?page_id=xxx
327
+ * 2. mgp 协议: mgp://mastergo.netease.com/file/174875497054651?nodeId=xxx
328
+ * 3. 纯 fileId: 174875497054651
329
+ *
330
+ * @returns fileId 或 null
331
+ */
332
+ export function extractFileId(input: string): string | null {
333
+ const trimmed = input.trim()
334
+
335
+ // 1. 纯数字 fileId
336
+ if (/^\d+$/.test(trimmed)) {
337
+ return trimmed
338
+ }
339
+
340
+ // 2. mgp:// 协议
341
+ if (trimmed.startsWith('mgp://')) {
342
+ return extractFileIdFromMgpLink(trimmed)
343
+ }
344
+
345
+ // 3. 完整 URL (http:// 或 https://)
346
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
347
+ return extractFileIdFromUrl(trimmed)
348
+ }
349
+
350
+ // 4. 尝试作为不带协议的 URL 解析
351
+ return extractFileIdFromUrl(trimmed)
352
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 常量测试
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest'
6
+ import {
7
+ DEFAULT_PORT,
8
+ PORT_RANGE_START,
9
+ PORT_RANGE_END,
10
+ MAX_PORT_ATTEMPTS,
11
+ HEARTBEAT_INTERVAL,
12
+ HEARTBEAT_TIMEOUT,
13
+ ConnectionType,
14
+ MessageType,
15
+ } from '../../../src/shared/constants.js'
16
+
17
+ describe('端口配置', () => {
18
+ it('应该有正确的默认端口', () => {
19
+ expect(DEFAULT_PORT).toBe(9527)
20
+ })
21
+
22
+ it('应该有正确的端口范围', () => {
23
+ expect(PORT_RANGE_START).toBe(9527)
24
+ expect(PORT_RANGE_END).toBe(9536)
25
+ })
26
+
27
+ it('端口范围应该正好是 10 个', () => {
28
+ expect(PORT_RANGE_END - PORT_RANGE_START + 1).toBe(MAX_PORT_ATTEMPTS)
29
+ })
30
+ })
31
+
32
+ describe('心跳配置', () => {
33
+ it('应该有正确的心跳间隔', () => {
34
+ expect(HEARTBEAT_INTERVAL).toBe(30000) // 30 秒
35
+ })
36
+
37
+ it('应该有正确的心跳超时', () => {
38
+ expect(HEARTBEAT_TIMEOUT).toBe(90000) // 90 秒 = 3 次心跳
39
+ })
40
+
41
+ it('心跳超时应该是间隔的 3 倍', () => {
42
+ expect(HEARTBEAT_TIMEOUT).toBe(HEARTBEAT_INTERVAL * 3)
43
+ })
44
+ })
45
+
46
+ describe('ConnectionType', () => {
47
+ it('应该有 CONSUMER 和 PROVIDER 两种类型', () => {
48
+ expect(ConnectionType.CONSUMER).toBe('consumer')
49
+ expect(ConnectionType.PROVIDER).toBe('provider')
50
+ })
51
+ })
52
+
53
+ describe('MessageType', () => {
54
+ it('应该包含系统消息类型', () => {
55
+ expect(MessageType.PING).toBe('ping')
56
+ expect(MessageType.PONG).toBe('pong')
57
+ expect(MessageType.REGISTER).toBe('register')
58
+ })
59
+
60
+ it('应该包含业务消息类型', () => {
61
+ expect(MessageType.GET_NODE_BY_ID).toBe('get_node_by_id')
62
+ expect(MessageType.GET_ALL_NODES).toBe('get_all_nodes')
63
+ expect(MessageType.EXPORT_IMAGE).toBe('export_image')
64
+ expect(MessageType.EXECUTE_CODE).toBe('execute_code')
65
+ })
66
+ })