@dcrays/dcgchat-test 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent.ts +3 -74
- package/src/bot.ts +5 -3
- package/src/skill.ts +3 -74
- package/src/tools/messageTool.ts +46 -14
- package/src/transport.ts +1 -1
- package/src/utils/zipExtract.ts +97 -0
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import axios from 'axios'
|
|
2
|
-
/** @ts-ignore */
|
|
3
|
-
import unzipper from 'unzipper'
|
|
4
|
-
import { pipeline } from 'stream/promises'
|
|
5
2
|
import fs from 'fs'
|
|
6
3
|
import path from 'path'
|
|
7
4
|
import { getWorkspaceDir } from './utils/global.js'
|
|
@@ -9,7 +6,7 @@ import { getWsConnection } from './utils/global.js'
|
|
|
9
6
|
import { dcgLogger } from './utils/log.js'
|
|
10
7
|
import { isWsOpen } from './transport.js'
|
|
11
8
|
import { sendMessageToGateway } from './gateway/socket.js'
|
|
12
|
-
import {
|
|
9
|
+
import { extractZipBufferToDirectory } from './utils/zipExtract.js'
|
|
13
10
|
|
|
14
11
|
type IAgentParams = {
|
|
15
12
|
url: string
|
|
@@ -110,81 +107,13 @@ export async function createAgent(msgContent: Record<string, any>) {
|
|
|
110
107
|
}
|
|
111
108
|
|
|
112
109
|
try {
|
|
113
|
-
// 下载 zip 文件
|
|
114
110
|
const response = await axios({
|
|
115
111
|
method: 'get',
|
|
116
112
|
url,
|
|
117
|
-
responseType: '
|
|
113
|
+
responseType: 'arraybuffer'
|
|
118
114
|
})
|
|
119
|
-
// 创建目标目录
|
|
120
115
|
fs.mkdirSync(workspaceDir, { recursive: true })
|
|
121
|
-
|
|
122
|
-
await new Promise((resolve, reject) => {
|
|
123
|
-
const tasks: Promise<void>[] = []
|
|
124
|
-
let rootDir: string | null = null
|
|
125
|
-
let hasError = false
|
|
126
|
-
|
|
127
|
-
response.data
|
|
128
|
-
.pipe(unzipper.Parse())
|
|
129
|
-
.on('entry', (entry: any) => {
|
|
130
|
-
if (hasError) {
|
|
131
|
-
entry.autodrain()
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
const flags = entry.props?.flags ?? 0
|
|
136
|
-
const entryPath = decodeZipEntryPath(entry.props?.pathBuffer, flags, entry.path)
|
|
137
|
-
const pathParts = entryPath.split('/')
|
|
138
|
-
|
|
139
|
-
// 检测根目录
|
|
140
|
-
if (!rootDir && pathParts.length > 1) {
|
|
141
|
-
rootDir = pathParts[0]
|
|
142
|
-
}
|
|
143
|
-
let newPath = entryPath
|
|
144
|
-
// 移除顶层文件夹
|
|
145
|
-
if (rootDir && entryPath.startsWith(rootDir + '/')) {
|
|
146
|
-
newPath = entryPath.slice(rootDir.length + 1)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (!newPath) {
|
|
150
|
-
entry.autodrain()
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const targetPath = path.join(workspaceDir, newPath)
|
|
155
|
-
|
|
156
|
-
if (entry.type === 'Directory') {
|
|
157
|
-
fs.mkdirSync(targetPath, { recursive: true })
|
|
158
|
-
entry.autodrain()
|
|
159
|
-
} else {
|
|
160
|
-
const parentDir = path.dirname(targetPath)
|
|
161
|
-
fs.mkdirSync(parentDir, { recursive: true })
|
|
162
|
-
const writeStream = fs.createWriteStream(targetPath)
|
|
163
|
-
const task = pipeline(entry, writeStream).catch((err) => {
|
|
164
|
-
hasError = true
|
|
165
|
-
throw new Error(`解压文件失败 ${entryPath}: ${err.message}`)
|
|
166
|
-
})
|
|
167
|
-
tasks.push(task)
|
|
168
|
-
}
|
|
169
|
-
} catch (err) {
|
|
170
|
-
hasError = true
|
|
171
|
-
entry.autodrain()
|
|
172
|
-
reject(new Error(`处理entry失败: ${err}`))
|
|
173
|
-
}
|
|
174
|
-
})
|
|
175
|
-
.on('close', async () => {
|
|
176
|
-
try {
|
|
177
|
-
await Promise.all(tasks)
|
|
178
|
-
resolve(null)
|
|
179
|
-
} catch (err) {
|
|
180
|
-
reject(err)
|
|
181
|
-
}
|
|
182
|
-
})
|
|
183
|
-
.on('error', (err: { message: any }) => {
|
|
184
|
-
hasError = true
|
|
185
|
-
reject(new Error(`解压流错误: ${err.message}`))
|
|
186
|
-
})
|
|
187
|
-
})
|
|
116
|
+
await extractZipBufferToDirectory(Buffer.from(response.data), workspaceDir)
|
|
188
117
|
await onCreateAgent(msgContent)
|
|
189
118
|
} catch (error) {
|
|
190
119
|
// 如果安装失败,清理目录
|
package/src/bot.ts
CHANGED
|
@@ -235,10 +235,10 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
235
235
|
const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
|
|
236
236
|
let streamedTextLen = 0
|
|
237
237
|
|
|
238
|
-
if (msg.content.skills_scope.length > 0) {
|
|
238
|
+
if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code) {
|
|
239
239
|
const workspaceDir = getWorkspaceDir()
|
|
240
240
|
const skillCode = msg.content.skills_scope.map((skill) => `${workspaceDir}/skills/${skill.skill_code}`).join('\n')
|
|
241
|
-
const skillText = `技能${skillCode} 在目录${skillCode}下,在目录${skillCode}
|
|
241
|
+
const skillText = `技能${skillCode} 在目录${skillCode}下,在目录${skillCode}下读取技能 \n`
|
|
242
242
|
text = skillText ? `${skillText} \n ${text}` : text
|
|
243
243
|
}
|
|
244
244
|
const prefixContext = createReplyPrefixContext({
|
|
@@ -385,7 +385,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
|
|
|
385
385
|
const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
|
|
386
386
|
streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
|
|
387
387
|
sendChunk(delta, outboundCtx, prev)
|
|
388
|
-
dcgLogger(
|
|
388
|
+
dcgLogger(
|
|
389
|
+
`[stream]: chunkIdx=${prev} len=${delta.length} sessionId=${outboundCtx.sessionId} ${delta.slice(0, 100)}`
|
|
390
|
+
)
|
|
389
391
|
}
|
|
390
392
|
streamedTextLen = payload.text.length
|
|
391
393
|
} else {
|
package/src/skill.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import axios from 'axios'
|
|
2
|
-
/** @ts-ignore */
|
|
3
|
-
import unzipper from 'unzipper'
|
|
4
|
-
import { pipeline } from 'stream/promises'
|
|
5
2
|
import fs from 'fs'
|
|
6
3
|
import path from 'path'
|
|
7
4
|
import { getWorkspaceDir } from './utils/global.js'
|
|
@@ -9,7 +6,7 @@ import { getWsConnection } from './utils/global.js'
|
|
|
9
6
|
import { dcgLogger } from './utils/log.js'
|
|
10
7
|
import { isWsOpen } from './transport.js'
|
|
11
8
|
import { sendMessageToGateway } from './gateway/socket.js'
|
|
12
|
-
import {
|
|
9
|
+
import { extractZipBufferToDirectory } from './utils/zipExtract.js'
|
|
13
10
|
|
|
14
11
|
type ISkillParams = {
|
|
15
12
|
path: string
|
|
@@ -47,81 +44,13 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
try {
|
|
50
|
-
// 下载 zip 文件
|
|
51
47
|
const response = await axios({
|
|
52
48
|
method: 'get',
|
|
53
49
|
url: cdnUrl,
|
|
54
|
-
responseType: '
|
|
50
|
+
responseType: 'arraybuffer'
|
|
55
51
|
})
|
|
56
|
-
// 创建目标目录
|
|
57
52
|
fs.mkdirSync(skillDir, { recursive: true })
|
|
58
|
-
|
|
59
|
-
await new Promise((resolve, reject) => {
|
|
60
|
-
const tasks: Promise<void>[] = []
|
|
61
|
-
let rootDir: string | null = null
|
|
62
|
-
let hasError = false
|
|
63
|
-
|
|
64
|
-
response.data
|
|
65
|
-
.pipe(unzipper.Parse())
|
|
66
|
-
.on('entry', (entry: any) => {
|
|
67
|
-
if (hasError) {
|
|
68
|
-
entry.autodrain()
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
try {
|
|
72
|
-
const flags = entry.props?.flags ?? 0
|
|
73
|
-
const entryPath = decodeZipEntryPath(entry.props?.pathBuffer, flags, entry.path)
|
|
74
|
-
const pathParts = entryPath.split('/')
|
|
75
|
-
|
|
76
|
-
// 检测根目录
|
|
77
|
-
if (!rootDir && pathParts.length > 1) {
|
|
78
|
-
rootDir = pathParts[0]
|
|
79
|
-
}
|
|
80
|
-
let newPath = entryPath
|
|
81
|
-
// 移除顶层文件夹
|
|
82
|
-
if (rootDir && entryPath.startsWith(rootDir + '/')) {
|
|
83
|
-
newPath = entryPath.slice(rootDir.length + 1)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (!newPath) {
|
|
87
|
-
entry.autodrain()
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const targetPath = path.join(skillDir, newPath)
|
|
92
|
-
|
|
93
|
-
if (entry.type === 'Directory') {
|
|
94
|
-
fs.mkdirSync(targetPath, { recursive: true })
|
|
95
|
-
entry.autodrain()
|
|
96
|
-
} else {
|
|
97
|
-
const parentDir = path.dirname(targetPath)
|
|
98
|
-
fs.mkdirSync(parentDir, { recursive: true })
|
|
99
|
-
const writeStream = fs.createWriteStream(targetPath)
|
|
100
|
-
const task = pipeline(entry, writeStream).catch((err) => {
|
|
101
|
-
hasError = true
|
|
102
|
-
throw new Error(`解压文件失败 ${entryPath}: ${err.message}`)
|
|
103
|
-
})
|
|
104
|
-
tasks.push(task)
|
|
105
|
-
}
|
|
106
|
-
} catch (err) {
|
|
107
|
-
hasError = true
|
|
108
|
-
entry.autodrain()
|
|
109
|
-
reject(new Error(`处理entry失败: ${err}`))
|
|
110
|
-
}
|
|
111
|
-
})
|
|
112
|
-
.on('close', async () => {
|
|
113
|
-
try {
|
|
114
|
-
await Promise.all(tasks)
|
|
115
|
-
resolve(null)
|
|
116
|
-
} catch (err) {
|
|
117
|
-
reject(err)
|
|
118
|
-
}
|
|
119
|
-
})
|
|
120
|
-
.on('error', (err: { message: any }) => {
|
|
121
|
-
hasError = true
|
|
122
|
-
reject(new Error(`解压流错误: ${err.message}`))
|
|
123
|
-
})
|
|
124
|
-
})
|
|
53
|
+
await extractZipBufferToDirectory(Buffer.from(response.data), skillDir)
|
|
125
54
|
sendEvent({ ...msgContent, status: 'ok' })
|
|
126
55
|
sendMessageToGateway(JSON.stringify({ method: 'skills.status', params: {} }))
|
|
127
56
|
} catch (error) {
|
package/src/tools/messageTool.ts
CHANGED
|
@@ -18,15 +18,26 @@ function toPosixPath(p: string): string {
|
|
|
18
18
|
return path.normalize(p.trim()).replace(/\\/g, '/')
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
|
|
22
|
+
function isPathInsideDir(filepath: string, rootDir: string): boolean {
|
|
23
|
+
const root = path.resolve(rootDir)
|
|
24
|
+
const resolved = path.resolve(filepath)
|
|
25
|
+
const rel = path.relative(root, resolved)
|
|
26
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
31
|
+
* 允许发送的路径:
|
|
32
|
+
* - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
|
|
33
|
+
* - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
|
|
24
34
|
*/
|
|
25
|
-
function isSafePath(filepath: string): boolean {
|
|
35
|
+
function isSafePath(filepath: string, workspaceDir?: string): boolean {
|
|
36
|
+
const ws = workspaceDir?.trim()
|
|
37
|
+
if (ws && isPathInsideDir(filepath, ws)) return true
|
|
26
38
|
const p = toPosixPath(filepath)
|
|
27
39
|
if (p.startsWith('/workspace/') || p === '/workspace') return true
|
|
28
|
-
if (p
|
|
29
|
-
// Windows: C:/workspace/...、c:/mobook/...
|
|
40
|
+
if (p === '/mobook') return true
|
|
30
41
|
return /^[A-Za-z]:\/(workspace|mobook)(\/|$)/.test(p)
|
|
31
42
|
}
|
|
32
43
|
|
|
@@ -64,7 +75,7 @@ const messageToolParameters = {
|
|
|
64
75
|
file: {
|
|
65
76
|
type: 'string',
|
|
66
77
|
description:
|
|
67
|
-
'
|
|
78
|
+
'文件绝对路径:须在「当前 Agent 工作区」目录下(如 /root/.openclaw/workspace-xxx/output/28337/slices_result.json),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)'
|
|
68
79
|
}
|
|
69
80
|
},
|
|
70
81
|
required: ['file']
|
|
@@ -74,12 +85,32 @@ const messageToolParameters = {
|
|
|
74
85
|
oneOf: [{ required: ['content'] }, { required: ['media'] }]
|
|
75
86
|
}
|
|
76
87
|
|
|
77
|
-
|
|
88
|
+
/** 从正文提取可发送的文件路径(固定挂载 + 当前工作区前缀)。 */
|
|
89
|
+
function extractPaths(text: string | undefined, workspaceDir?: string): string[] {
|
|
78
90
|
if (!text) return []
|
|
79
91
|
const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
|
|
80
|
-
// Windows: C:\workspace\...、C:/mobook/...(不含空白)
|
|
81
92
|
const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
|
|
82
|
-
|
|
93
|
+
const underWs: string[] = []
|
|
94
|
+
const ws = workspaceDir?.trim()
|
|
95
|
+
if (ws) {
|
|
96
|
+
const variants = new Set<string>()
|
|
97
|
+
variants.add(ws)
|
|
98
|
+
variants.add(toPosixPath(ws))
|
|
99
|
+
if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
|
|
100
|
+
for (const prefix of variants) {
|
|
101
|
+
if (!prefix) continue
|
|
102
|
+
let from = 0
|
|
103
|
+
while (from < text.length) {
|
|
104
|
+
const i = text.indexOf(prefix, from)
|
|
105
|
+
if (i === -1) break
|
|
106
|
+
let end = i + prefix.length
|
|
107
|
+
while (end < text.length && !/\s/.test(text[end])) end++
|
|
108
|
+
underWs.push(text.slice(i, end))
|
|
109
|
+
from = i + 1
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...new Set([...unix, ...win, ...underWs])]
|
|
83
114
|
}
|
|
84
115
|
|
|
85
116
|
function isSafeFile(filepath: string) {
|
|
@@ -104,8 +135,8 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
|
|
|
104
135
|
向用户发送消息。
|
|
105
136
|
若传 target,target 必须是 sessionKey,不能是 userId。
|
|
106
137
|
如果发送附件:必须使用 media 字段
|
|
107
|
-
|
|
108
|
-
|
|
138
|
+
文件路径须在当前 Agent 工作区目录下(随部署变化,如 ~/.openclaw/workspace-xxx/...),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)。
|
|
139
|
+
禁止在正文中直接输出可访问路径(应通过 media 发送)
|
|
109
140
|
`,
|
|
110
141
|
parameters: messageToolParameters,
|
|
111
142
|
execute: async (_toolCallId, args, signal) => {
|
|
@@ -123,12 +154,13 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
|
|
|
123
154
|
try {
|
|
124
155
|
const sentFiles = new Set<string>()
|
|
125
156
|
const sentKeys = new Set<string>()
|
|
157
|
+
const workspaceDir = pluginCtx.workspaceDir
|
|
126
158
|
|
|
127
159
|
if (args.media?.length) {
|
|
128
160
|
for (const media of args.media) {
|
|
129
161
|
const filepath = media.file
|
|
130
162
|
if (!filepath) continue
|
|
131
|
-
if (!isSafePath(filepath)) continue
|
|
163
|
+
if (!isSafePath(filepath, workspaceDir)) continue
|
|
132
164
|
if (!isSafeFile(filepath)) continue
|
|
133
165
|
const key = pathKey(filepath)
|
|
134
166
|
if (sentKeys.has(key)) continue
|
|
@@ -139,9 +171,9 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
|
|
|
139
171
|
}
|
|
140
172
|
}
|
|
141
173
|
|
|
142
|
-
const fallbackPaths = extractPaths(args.content)
|
|
174
|
+
const fallbackPaths = extractPaths(args.content, workspaceDir)
|
|
143
175
|
for (const filepath of fallbackPaths) {
|
|
144
|
-
if (!isSafePath(filepath)) continue
|
|
176
|
+
if (!isSafePath(filepath, workspaceDir)) continue
|
|
145
177
|
if (!isSafeFile(filepath)) continue
|
|
146
178
|
const key = pathKey(filepath)
|
|
147
179
|
if (sentKeys.has(key)) continue
|
package/src/transport.ts
CHANGED
|
@@ -169,7 +169,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
export function sendFinal(ctx: IMsgParams, tag: string): boolean {
|
|
172
|
-
dcgLogger(` message handling complete state: final tag:${tag}`)
|
|
172
|
+
dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
|
|
173
173
|
return wsSend(ctx, { response: '', state: 'final' })
|
|
174
174
|
}
|
|
175
175
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
/** @ts-ignore */
|
|
4
|
+
import unzipper from 'unzipper'
|
|
5
|
+
import { pipeline } from 'stream/promises'
|
|
6
|
+
import { decodeZipEntryPath } from './zipPath.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 若且唯若所有条目都在同一顶层目录下(如 GitHub 下载的 repo-name/...),返回该目录名;否则返回 null。
|
|
10
|
+
* 不能再用「第一个多段路径的第一段」推断,否则 ZIP 条目顺序变化时会误判(例如先出现 .github/ 或 src/)。
|
|
11
|
+
*/
|
|
12
|
+
export function computeSharedZipRootPrefix(decodedPaths: string[]): string | null {
|
|
13
|
+
const normalized = decodedPaths
|
|
14
|
+
.map((p) => p.replace(/\\/g, '/').replace(/\/+$/, ''))
|
|
15
|
+
.filter((p) => p.length > 0)
|
|
16
|
+
|
|
17
|
+
if (normalized.length === 0) return null
|
|
18
|
+
|
|
19
|
+
const firstSegs = new Set<string>()
|
|
20
|
+
for (const p of normalized) {
|
|
21
|
+
const seg = p.split('/').filter(Boolean)[0]
|
|
22
|
+
if (seg) firstSegs.add(seg)
|
|
23
|
+
}
|
|
24
|
+
if (firstSegs.size !== 1) return null
|
|
25
|
+
|
|
26
|
+
const root = [...firstSegs][0]!
|
|
27
|
+
const prefix = `${root}/`
|
|
28
|
+
for (const p of normalized) {
|
|
29
|
+
if (p !== root && !p.startsWith(prefix)) return null
|
|
30
|
+
}
|
|
31
|
+
return root
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertSafeZipTarget(destDir: string, relativePath: string): string {
|
|
35
|
+
const resolvedPath = path.resolve(destDir, relativePath)
|
|
36
|
+
const resolvedDest = path.resolve(destDir)
|
|
37
|
+
const rel = path.relative(resolvedDest, resolvedPath)
|
|
38
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
39
|
+
throw new Error(`zip 路径越界: ${relativePath}`)
|
|
40
|
+
}
|
|
41
|
+
return resolvedPath
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ZipEntry = {
|
|
45
|
+
path: string
|
|
46
|
+
pathBuffer: Buffer
|
|
47
|
+
flags: number
|
|
48
|
+
type: string
|
|
49
|
+
stream: (password?: string) => NodeJS.ReadableStream
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 将已下载的 zip 解压到 destDir;顶层单根目录(若存在)会被剥掉,与原先流式 Parse 行为一致,但根目录由全量路径计算,与条目顺序无关。
|
|
54
|
+
*/
|
|
55
|
+
export async function extractZipBufferToDirectory(buf: Buffer, destDir: string): Promise<void> {
|
|
56
|
+
const directory = await unzipper.Open.buffer(buf)
|
|
57
|
+
const files = (await directory.files) as ZipEntry[]
|
|
58
|
+
|
|
59
|
+
const decodedPaths = files.map((entry) =>
|
|
60
|
+
decodeZipEntryPath(entry.pathBuffer, entry.flags ?? 0, entry.path)
|
|
61
|
+
)
|
|
62
|
+
const rootDir = computeSharedZipRootPrefix(decodedPaths)
|
|
63
|
+
|
|
64
|
+
// 与 unzipper 默认 extract 一致:串行读各 entry,避免同一 buffer 上多路解压竞争
|
|
65
|
+
for (let i = 0; i < files.length; i++) {
|
|
66
|
+
const entry = files[i]!
|
|
67
|
+
const entryPath = decodedPaths[i]!
|
|
68
|
+
let newPath = entryPath.replace(/\\/g, '/')
|
|
69
|
+
if (rootDir) {
|
|
70
|
+
if (newPath === rootDir || newPath === `${rootDir}/`) {
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (newPath.startsWith(`${rootDir}/`)) {
|
|
74
|
+
newPath = newPath.slice(rootDir.length + 1)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
newPath = newPath.replace(/\/+$/, '')
|
|
78
|
+
if (!newPath) continue
|
|
79
|
+
|
|
80
|
+
const targetPath = assertSafeZipTarget(destDir, newPath)
|
|
81
|
+
|
|
82
|
+
if (entry.type === 'Directory') {
|
|
83
|
+
fs.mkdirSync(targetPath, { recursive: true })
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parentDir = path.dirname(targetPath)
|
|
88
|
+
fs.mkdirSync(parentDir, { recursive: true })
|
|
89
|
+
const writeStream = fs.createWriteStream(targetPath)
|
|
90
|
+
try {
|
|
91
|
+
await pipeline(entry.stream(), writeStream)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
94
|
+
throw new Error(`解压文件失败 ${entryPath}: ${message}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|