dsh-plugin-pack 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +537 -3
- package/bin/dsh-plugin-pack.js +200 -0
- package/client.js +2337 -0
- package/cordis.patch.yml +24 -0
- package/hotload.js +230 -0
- package/lib/export.js +452 -0
- package/lib/home.js +75 -0
- package/lib/import-plugin.js +356 -0
- package/lib/import-tgz.js +115 -0
- package/lib/import.js +831 -0
- package/lib/inbox.js +194 -0
- package/lib/index.js +725 -0
- package/lib/jobs.js +208 -0
- package/lib/patch-text.js +672 -0
- package/lib/scan.js +488 -0
- package/lib/server.js +926 -0
- package/lib/tar.js +241 -0
- package/lib/util.js +333 -0
- package/lib/zip-stream.js +166 -0
- package/lib/zip.js +514 -0
- package/package.json +72 -6
package/lib/tar.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm tarball(.tgz)导出:单个插件用这种格式。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不沿用整合包的 zip:
|
|
5
|
+
* - tarball 是 npm 生态的标准单包格式,别人拿到能直接 `npm i ./x.tgz`
|
|
6
|
+
* - 目录必须叫 `package/`,包里放 package.json 就是完整的一个 npm 包
|
|
7
|
+
* - gzip 压缩,比 zip 通常还小一点
|
|
8
|
+
*
|
|
9
|
+
* 格式细节(按 npm pack 的产出):
|
|
10
|
+
* package/package.json
|
|
11
|
+
* package/<其余文件按原相对路径>
|
|
12
|
+
* package/node_modules/<dep>/... 扁平化依赖,装上就能跑
|
|
13
|
+
*
|
|
14
|
+
* tar 结构:每个文件 512 字节头 + 内容(向上取整到 512),最后两个全零块。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-plugin-pack/tar
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import zlib from 'node:zlib';
|
|
21
|
+
|
|
22
|
+
/** tar 头部长度。 */
|
|
23
|
+
const BLOCK = 512;
|
|
24
|
+
|
|
25
|
+
/** 写入定长字符串字段(以 NUL 补齐)。 */
|
|
26
|
+
function writeString(header, offset, value, length) {
|
|
27
|
+
const buf = Buffer.from(String(value ?? ''), 'utf8').subarray(0, length);
|
|
28
|
+
buf.copy(header, offset);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 写入八进制数字字段。
|
|
33
|
+
*
|
|
34
|
+
* @param {Buffer} header
|
|
35
|
+
* @param {number} offset
|
|
36
|
+
* @param {number} value
|
|
37
|
+
* @param {number} length 含结尾 NUL 的字段长度
|
|
38
|
+
*/
|
|
39
|
+
function writeOctal(header, offset, value, length) {
|
|
40
|
+
const text = Math.max(0, Math.floor(value)).toString(8).padStart(length - 1, '0');
|
|
41
|
+
writeString(header, offset, `${text}\0`, length);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 求字符串字段的校验和(所有字节相加)。
|
|
46
|
+
*
|
|
47
|
+
* @param {Buffer} header
|
|
48
|
+
* @returns {string} 6 位八进制 + NUL + 空格
|
|
49
|
+
*/
|
|
50
|
+
function checksumOf(header) {
|
|
51
|
+
let sum = 0;
|
|
52
|
+
for (const byte of header) sum += byte;
|
|
53
|
+
return `${sum.toString(8).padStart(6, '0')}\0 `;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 构造一个文件的 tar 头。
|
|
58
|
+
*
|
|
59
|
+
* @param {object} options
|
|
60
|
+
* @param {string} options.name 归档内路径(如 package/package.json)
|
|
61
|
+
* @param {number} options.size 字节数
|
|
62
|
+
* @param {number} [options.mode] 权限位
|
|
63
|
+
* @param {number} [options.mtime] 秒级时间戳
|
|
64
|
+
* @param {'0'|'5'} [options.type] '0'=普通文件,'5'=目录
|
|
65
|
+
* @returns {Buffer} 512 字节
|
|
66
|
+
*/
|
|
67
|
+
export function tarHeader({ name, size, mode = 0o644, mtime = 0, type = '0' }) {
|
|
68
|
+
const header = Buffer.alloc(BLOCK);
|
|
69
|
+
const fullName = String(name);
|
|
70
|
+
const nameBytes = Buffer.byteLength(fullName, 'utf8');
|
|
71
|
+
|
|
72
|
+
if (nameBytes <= 100) {
|
|
73
|
+
writeString(header, 0, fullName, 100);
|
|
74
|
+
} else {
|
|
75
|
+
// 超长:拆成 prefix(<=155) + name(<=100)。
|
|
76
|
+
// 从右往左找第一个能让"后半段 <=100 字节、前半段 <=155 字节"的 '/'。
|
|
77
|
+
const chars = [...fullName];
|
|
78
|
+
let splitAt = -1;
|
|
79
|
+
for (let i = chars.length - 1; i > 0; i -= 1) {
|
|
80
|
+
if (chars[i] !== '/') continue;
|
|
81
|
+
const base = chars.slice(i + 1).join('');
|
|
82
|
+
const prefix = chars.slice(0, i).join('');
|
|
83
|
+
if (!base) continue;
|
|
84
|
+
if (Buffer.byteLength(base, 'utf8') <= 100 && Buffer.byteLength(prefix, 'utf8') <= 155) {
|
|
85
|
+
splitAt = i;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (splitAt < 0) {
|
|
90
|
+
throw new Error(`归档路径过长(tar 极限 name≤100 + prefix≤155 字节),无法写入: ${fullName}`);
|
|
91
|
+
}
|
|
92
|
+
writeString(header, 345, chars.slice(0, splitAt).join(''), 155);
|
|
93
|
+
writeString(header, 0, chars.slice(splitAt + 1).join(''), 100);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
writeOctal(header, 100, mode, 8);
|
|
97
|
+
writeOctal(header, 108, 0, 8); // uid
|
|
98
|
+
writeOctal(header, 116, 0, 8); // gid
|
|
99
|
+
writeOctal(header, 124, size, 12);
|
|
100
|
+
writeOctal(header, 136, Math.floor(mtime / 1000), 12);
|
|
101
|
+
writeString(header, 148, ' ', 8); // 校验和字段先填空格
|
|
102
|
+
writeString(header, 156, type, 1);
|
|
103
|
+
writeString(header, 257, 'ustar\0', 6);
|
|
104
|
+
writeString(header, 263, '00', 2);
|
|
105
|
+
writeString(header, 265, 'root', 32);
|
|
106
|
+
writeString(header, 297, 'root', 32);
|
|
107
|
+
writeString(header, 329, '0000000', 8);
|
|
108
|
+
writeString(header, 337, '0000000', 8);
|
|
109
|
+
header.write(checksumOf(header), 148, 'ascii');
|
|
110
|
+
return header;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 把长度补齐到 512 的整数倍。 */
|
|
114
|
+
function padding(size) {
|
|
115
|
+
const rest = size % BLOCK;
|
|
116
|
+
return rest === 0 ? 0 : BLOCK - rest;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 把条目流写成 .tgz(异步)。
|
|
121
|
+
*
|
|
122
|
+
* 两个"不能同步"的理由:
|
|
123
|
+
* 1. `gzipSync` 会卡死整个事件循环 —— 实测 64 MiB 同步 gzip 期间
|
|
124
|
+
* 定时器一次都没触发。dsh 的对话等请求全在同一个循环里,会一起卡住。
|
|
125
|
+
* 2. 大插件的 tar 可能有几百 MiB,Buffer.concat 会翻倍峰值内存。
|
|
126
|
+
*
|
|
127
|
+
* 所以:tar 块直接管道进 zlib 的 gzip 流,边压边写盘。
|
|
128
|
+
*
|
|
129
|
+
* @param {Iterable<{name: string, file?: string, data?: Buffer|string}>|AsyncIterable<any>} entries
|
|
130
|
+
* @param {string} outFile
|
|
131
|
+
* @param {{ maxBytes?: number, mtime?: number }} [options]
|
|
132
|
+
* @returns {Promise<{ bytes: number, entries: number, rawBytes: number }>}
|
|
133
|
+
*/
|
|
134
|
+
export async function writeTgzStream(entries, outFile, options = {}) {
|
|
135
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 ? Math.floor(options.maxBytes) : Infinity;
|
|
136
|
+
const mtime = Number.isFinite(options.mtime) ? options.mtime : Date.now();
|
|
137
|
+
|
|
138
|
+
const outDir = path.dirname(path.resolve(outFile));
|
|
139
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const out = fs.createWriteStream(outFile);
|
|
142
|
+
const gzip = zlib.createGzip({ level: 9 });
|
|
143
|
+
gzip.pipe(out);
|
|
144
|
+
|
|
145
|
+
let count = 0;
|
|
146
|
+
let raw = 0;
|
|
147
|
+
let compressed = 0;
|
|
148
|
+
out.on('data', () => {});
|
|
149
|
+
// 统计压缩后大小
|
|
150
|
+
const onGzipData = (chunk) => {
|
|
151
|
+
compressed += chunk.length;
|
|
152
|
+
};
|
|
153
|
+
gzip.on('data', onGzipData);
|
|
154
|
+
|
|
155
|
+
/** 等 gzip 流把数据吞下去(尊重背压)。 */
|
|
156
|
+
const writeChunk = (buffer) =>
|
|
157
|
+
new Promise((resolve, reject) => {
|
|
158
|
+
raw += buffer.length;
|
|
159
|
+
if (raw > maxBytes) {
|
|
160
|
+
const error = new Error(`归档超过体积上限(已到 ${(raw / 1048576).toFixed(1)} MiB)`);
|
|
161
|
+
error.code = 'TOO_LARGE';
|
|
162
|
+
reject(error);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (gzip.write(buffer)) resolve();
|
|
166
|
+
else gzip.once('drain', resolve);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const finished = new Promise((resolve, reject) => {
|
|
170
|
+
out.on('finish', resolve);
|
|
171
|
+
out.on('error', reject);
|
|
172
|
+
gzip.on('error', reject);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
for await (const item of entries) {
|
|
177
|
+
if (!item || typeof item.name !== 'string' || !item.name) {
|
|
178
|
+
throw new Error('writeTgzStream: 条目缺少 name');
|
|
179
|
+
}
|
|
180
|
+
const name = String(item.name).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
181
|
+
const data = item.file ? await fs.promises.readFile(item.file) : toBuf(item.data);
|
|
182
|
+
await writeChunk(tarHeader({ name, size: data.length, mtime }));
|
|
183
|
+
await writeChunk(data);
|
|
184
|
+
const pad = padding(data.length);
|
|
185
|
+
if (pad) await writeChunk(Buffer.alloc(pad));
|
|
186
|
+
count += 1;
|
|
187
|
+
}
|
|
188
|
+
// tar 结尾:两个全零块
|
|
189
|
+
await writeChunk(Buffer.alloc(BLOCK));
|
|
190
|
+
await writeChunk(Buffer.alloc(BLOCK));
|
|
191
|
+
} finally {
|
|
192
|
+
gzip.end();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await finished;
|
|
196
|
+
gzip.off('data', onGzipData);
|
|
197
|
+
return { bytes: compressed, entries: count, rawBytes: raw };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 统一输入类型。 */
|
|
201
|
+
function toBuf(value) {
|
|
202
|
+
if (Buffer.isBuffer(value)) return value;
|
|
203
|
+
if (typeof value === 'string') return Buffer.from(value, 'utf8');
|
|
204
|
+
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
|
205
|
+
if (ArrayBuffer.isView(value)) return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
206
|
+
throw new Error(`writeTgzStream: 不支持的数据类型 ${Object.prototype.toString.call(value)}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 读 .tgz 的条目(测试和导入校验用)。
|
|
211
|
+
*
|
|
212
|
+
* @param {Buffer} buffer
|
|
213
|
+
* @returns {Array<{ name: string, data: Buffer }>}
|
|
214
|
+
*/
|
|
215
|
+
export function readTgz(buffer) {
|
|
216
|
+
const tar = zlib.gunzipSync(buffer);
|
|
217
|
+
const entries = [];
|
|
218
|
+
let offset = 0;
|
|
219
|
+
while (offset + BLOCK <= tar.length) {
|
|
220
|
+
const header = tar.subarray(offset, offset + BLOCK);
|
|
221
|
+
// 全零块 = 结束
|
|
222
|
+
if (header.every((byte) => byte === 0)) break;
|
|
223
|
+
|
|
224
|
+
const rawName = header.toString('utf8', 0, 100).replace(/\0.*$/, '');
|
|
225
|
+
const prefix = header.toString('utf8', 345, 500).replace(/\0.*$/, '');
|
|
226
|
+
const name = prefix ? `${prefix}/${rawName}` : rawName;
|
|
227
|
+
const sizeText = header.toString('utf8', 124, 136).replace(/\0.*$/, '').trim();
|
|
228
|
+
const size = sizeText ? parseInt(sizeText, 8) : 0;
|
|
229
|
+
const type = header.toString('utf8', 156, 157);
|
|
230
|
+
|
|
231
|
+
offset += BLOCK;
|
|
232
|
+
if (type === '0' || type === '\0' || type === '') {
|
|
233
|
+
entries.push({ name, data: Buffer.from(tar.subarray(offset, offset + size)) });
|
|
234
|
+
offset += size + padding(size);
|
|
235
|
+
} else {
|
|
236
|
+
// 目录等非普通文件:跳过
|
|
237
|
+
offset += size + padding(size);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return entries;
|
|
241
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件系统小工具:只做本插件需要的那几件事,不引第三方依赖。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-plugin-pack/util
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** 同步读 JSON,失败返回 fallback。 */
|
|
10
|
+
export function readJson(file, fallback = null) {
|
|
11
|
+
try {
|
|
12
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
13
|
+
return JSON.parse(text);
|
|
14
|
+
} catch {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 同步读文本,失败返回 fallback。 */
|
|
20
|
+
export function readText(file, fallback = null) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(file, 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 写 JSON,保持 2 空格缩进 + 末尾换行。 */
|
|
29
|
+
export function writeJson(file, value) {
|
|
30
|
+
ensureDir(path.dirname(file));
|
|
31
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 写文本(保证父目录存在)。 */
|
|
35
|
+
export function writeText(file, text) {
|
|
36
|
+
ensureDir(path.dirname(file));
|
|
37
|
+
fs.writeFileSync(file, text, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* mkdir -p。
|
|
42
|
+
*
|
|
43
|
+
* 已存在的目录直接返回:对盘符根(D:\)调用 mkdirSync 会抛 EPERM,
|
|
44
|
+
* 因为 Windows 不允许"创建"一个已经存在的 root。
|
|
45
|
+
*/
|
|
46
|
+
export function ensureDir(dir) {
|
|
47
|
+
try {
|
|
48
|
+
if (fs.statSync(dir).isDirectory()) return;
|
|
49
|
+
} catch {
|
|
50
|
+
/* 不存在,往下创建 */
|
|
51
|
+
}
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 存在且是文件。 */
|
|
56
|
+
export function isFile(target) {
|
|
57
|
+
try {
|
|
58
|
+
return fs.statSync(target).isFile();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 存在且是目录(跟随符号链接)。 */
|
|
65
|
+
export function isDir(target) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.statSync(target).isDirectory();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** realpath,失败时原样返回。 */
|
|
74
|
+
export function realOrSelf(target) {
|
|
75
|
+
try {
|
|
76
|
+
return fs.realpathSync.native(target);
|
|
77
|
+
} catch {
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync(target);
|
|
80
|
+
} catch {
|
|
81
|
+
return target;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 递归删除,不存在不报错。 */
|
|
87
|
+
export function removeTree(target) {
|
|
88
|
+
try {
|
|
89
|
+
fs.rmSync(target, { recursive: true, force: true, maxRetries: 3 });
|
|
90
|
+
} catch {
|
|
91
|
+
/* 被占用时留给调用方处理 */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 递归复制(跟随符号链接,pnpm 的 store 链接需要这个)。 */
|
|
96
|
+
export function copyTree(from, to, { filter } = {}) {
|
|
97
|
+
ensureDir(path.dirname(to));
|
|
98
|
+
fs.cpSync(from, to, {
|
|
99
|
+
recursive: true,
|
|
100
|
+
dereference: true,
|
|
101
|
+
force: true,
|
|
102
|
+
errorOnExist: false,
|
|
103
|
+
filter: filter
|
|
104
|
+
? (src) => filter(src)
|
|
105
|
+
: undefined,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 目录体积与文件数(同步版,仅用于短的/测试路径)。 */
|
|
110
|
+
export function measureTree(root, options = {}) {
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
let files = 0;
|
|
113
|
+
for (const item of walkTree(root, options)) {
|
|
114
|
+
bytes += item.size;
|
|
115
|
+
files += 1;
|
|
116
|
+
}
|
|
117
|
+
return { bytes, files };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 目录体积与文件数(异步版)。
|
|
122
|
+
*
|
|
123
|
+
* 扫描插件目录动辄上万个文件,同步版会把事件循环卡住好几秒,
|
|
124
|
+
* dsh 的对话等请求一起无响应 —— 所以导出/扫描路径必须走这个。
|
|
125
|
+
*/
|
|
126
|
+
export async function measureTreeAsync(root, options = {}) {
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
let files = 0;
|
|
129
|
+
for await (const item of walkTreeAsync(root, options)) {
|
|
130
|
+
bytes += item.size;
|
|
131
|
+
files += 1;
|
|
132
|
+
}
|
|
133
|
+
return { bytes, files };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 遍历目录下所有文件(异步)。
|
|
138
|
+
*
|
|
139
|
+
* 为什么异步:导出要遍历上万个文件,`readdirSync` + `statSync` 会把
|
|
140
|
+
* 事件循环卡住好几秒,dsh 的对话等请求跟着无响应。
|
|
141
|
+
* 这里全部用 fs.promises,并在每个目录之间主动让出一次。
|
|
142
|
+
*
|
|
143
|
+
* @param {string} root
|
|
144
|
+
* @param {{ skipDirs?: Set<string>, keepFile?: (name: string) => boolean }} [options]
|
|
145
|
+
* @returns {AsyncGenerator<{ path: string, rel: string, name: string, size: number }>}
|
|
146
|
+
*/
|
|
147
|
+
export async function* walkTreeAsync(root, options = {}) {
|
|
148
|
+
const skipDirs = options.skipDirs ?? new Set();
|
|
149
|
+
const keepFile = options.keepFile ?? (() => true);
|
|
150
|
+
const stack = [{ dir: root, rel: '' }];
|
|
151
|
+
|
|
152
|
+
while (stack.length) {
|
|
153
|
+
const current = stack.pop();
|
|
154
|
+
let entries;
|
|
155
|
+
try {
|
|
156
|
+
entries = await fs.promises.readdir(current.dir, { withFileTypes: true });
|
|
157
|
+
} catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const full = path.join(current.dir, entry.name);
|
|
162
|
+
const rel = current.rel ? `${current.rel}/${entry.name}` : entry.name;
|
|
163
|
+
|
|
164
|
+
let isDir = entry.isDirectory();
|
|
165
|
+
let isFile = entry.isFile();
|
|
166
|
+
|
|
167
|
+
if (entry.isSymbolicLink()) {
|
|
168
|
+
try {
|
|
169
|
+
const stat = await fs.promises.stat(full);
|
|
170
|
+
isDir = stat.isDirectory();
|
|
171
|
+
isFile = stat.isFile();
|
|
172
|
+
if (isDir && skipDirs.has(entry.name)) continue;
|
|
173
|
+
if (isDir) {
|
|
174
|
+
stack.push({ dir: full, rel });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (isFile && keepFile(entry.name)) {
|
|
178
|
+
yield { path: full, rel, name: entry.name, size: stat.size };
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (isDir) {
|
|
187
|
+
if (skipDirs.has(entry.name)) continue;
|
|
188
|
+
stack.push({ dir: full, rel });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (isFile) {
|
|
192
|
+
if (!keepFile(entry.name)) continue;
|
|
193
|
+
let size = 0;
|
|
194
|
+
try {
|
|
195
|
+
size = (await fs.promises.stat(full)).size;
|
|
196
|
+
} catch {
|
|
197
|
+
size = 0;
|
|
198
|
+
}
|
|
199
|
+
yield { path: full, rel, name: entry.name, size };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// 每个目录之间让出一次事件循环,避免长时间占用
|
|
203
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 遍历目录下所有文件(同步版)。
|
|
209
|
+
*
|
|
210
|
+
* @param {string} root
|
|
211
|
+
* @param {{ skipDirs?: Set<string>, keepFile?: (name: string) => boolean }} [options]
|
|
212
|
+
* keepFile 返回 true 表示保留该文件(默认全保留);skipDirs 命中的目录整棵跳过。
|
|
213
|
+
* @returns {Generator<{ path: string, rel: string, name: string, size: number }>}
|
|
214
|
+
*/
|
|
215
|
+
export function* walkTree(root, options = {}) {
|
|
216
|
+
const skipDirs = options.skipDirs ?? new Set();
|
|
217
|
+
const keepFile = options.keepFile ?? (() => true);
|
|
218
|
+
const stack = [{ dir: root, rel: '' }];
|
|
219
|
+
while (stack.length) {
|
|
220
|
+
const current = stack.pop();
|
|
221
|
+
let entries;
|
|
222
|
+
try {
|
|
223
|
+
entries = fs.readdirSync(current.dir, { withFileTypes: true });
|
|
224
|
+
} catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
const full = path.join(current.dir, entry.name);
|
|
229
|
+
const rel = current.rel ? `${current.rel}/${entry.name}` : entry.name;
|
|
230
|
+
if (entry.isSymbolicLink()) {
|
|
231
|
+
let stat;
|
|
232
|
+
try {
|
|
233
|
+
stat = fs.statSync(full);
|
|
234
|
+
} catch {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (stat.isDirectory()) {
|
|
238
|
+
if (skipDirs.has(entry.name)) continue;
|
|
239
|
+
stack.push({ dir: full, rel });
|
|
240
|
+
} else if (stat.isFile()) {
|
|
241
|
+
if (!keepFile(entry.name)) continue;
|
|
242
|
+
yield { path: full, rel, name: entry.name, size: stat.size };
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (entry.isDirectory()) {
|
|
247
|
+
if (skipDirs.has(entry.name)) continue;
|
|
248
|
+
stack.push({ dir: full, rel });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (entry.isFile()) {
|
|
252
|
+
if (!keepFile(entry.name)) continue;
|
|
253
|
+
let size = 0;
|
|
254
|
+
try {
|
|
255
|
+
size = fs.statSync(full).size;
|
|
256
|
+
} catch {
|
|
257
|
+
size = 0;
|
|
258
|
+
}
|
|
259
|
+
yield { path: full, rel, name: entry.name, size };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 目录名是否落在排除名单里。 */
|
|
266
|
+
export const SKIP_DIRS = new Set([
|
|
267
|
+
'.git',
|
|
268
|
+
'.hg',
|
|
269
|
+
'.svn',
|
|
270
|
+
'.cache',
|
|
271
|
+
'.turbo',
|
|
272
|
+
'.next',
|
|
273
|
+
'.nuxt',
|
|
274
|
+
'.output',
|
|
275
|
+
'.venv',
|
|
276
|
+
'__pycache__',
|
|
277
|
+
'.pytest_cache',
|
|
278
|
+
'.mypy_cache',
|
|
279
|
+
'coverage',
|
|
280
|
+
'.nyc_output',
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
/** 永远不打包的文件。 */
|
|
284
|
+
export function skipAlways(name) {
|
|
285
|
+
return (
|
|
286
|
+
name === '.DS_Store' ||
|
|
287
|
+
name === 'Thumbs.db' ||
|
|
288
|
+
name === 'desktop.ini' ||
|
|
289
|
+
name.endsWith('.log') ||
|
|
290
|
+
name.endsWith('.tsbuildinfo')
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const SECRET_NAMES = new Set([
|
|
295
|
+
'.env',
|
|
296
|
+
'.npmrc',
|
|
297
|
+
'.netrc',
|
|
298
|
+
'credentials.json',
|
|
299
|
+
'auth.json',
|
|
300
|
+
'secrets.json',
|
|
301
|
+
'token.json',
|
|
302
|
+
'id_rsa',
|
|
303
|
+
'id_ed25519',
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
const SECRET_SUFFIX = ['.pem', '.key', '.p12', '.pfx', '.keystore', '.jks', '.token', '.secret'];
|
|
307
|
+
|
|
308
|
+
/** 疑似密钥/凭证。默认不打包,除非用户显式 includeSecrets。 */
|
|
309
|
+
export function looksSecret(name) {
|
|
310
|
+
const lower = name.toLowerCase();
|
|
311
|
+
if (SECRET_NAMES.has(lower)) return true;
|
|
312
|
+
if (lower.startsWith('.env.')) return true;
|
|
313
|
+
return SECRET_SUFFIX.some((suffix) => lower.endsWith(suffix));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 组合出当前场景下的文件过滤器:返回 true = 保留(可打包/可复制)。 */
|
|
317
|
+
export function fileFilter({ includeSecrets = false } = {}) {
|
|
318
|
+
return (name) => {
|
|
319
|
+
if (skipAlways(name)) return false;
|
|
320
|
+
if (!includeSecrets && looksSecret(name)) return false;
|
|
321
|
+
return true;
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 把字符串安全写入文件名。 */
|
|
326
|
+
export function slug(value) {
|
|
327
|
+
return String(value ?? '')
|
|
328
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
|
|
329
|
+
.replace(/\s+/g, '-')
|
|
330
|
+
.replace(/-+/g, '-')
|
|
331
|
+
.replace(/^-|-$/g, '')
|
|
332
|
+
.slice(0, 80) || 'plugin';
|
|
333
|
+
}
|