@namewta/speculo 0.7.5 → 0.8.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.
Files changed (43) hide show
  1. package/README.md +4 -5
  2. package/dist/src/cli.js +18 -13
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/config.d.ts +20 -0
  5. package/dist/src/config.js +94 -0
  6. package/dist/src/config.js.map +1 -0
  7. package/dist/src/index.d.ts +3 -2
  8. package/dist/src/index.js +56 -27
  9. package/dist/src/index.js.map +1 -1
  10. package/dist/src/manifest.d.ts +20 -0
  11. package/dist/src/manifest.js +57 -0
  12. package/dist/src/manifest.js.map +1 -0
  13. package/dist/src/refresh.d.ts +30 -0
  14. package/dist/src/refresh.js +465 -0
  15. package/dist/src/refresh.js.map +1 -0
  16. package/dist/src/structured.d.ts +12 -0
  17. package/dist/src/structured.js +236 -0
  18. package/dist/src/structured.js.map +1 -0
  19. package/package.json +2 -2
  20. package/template/.speculo/README.md +12 -11
  21. package/template/.speculo/refresh-contract.json +30 -0
  22. package/template/canonical/canonical-specdev-goal-plan.md +436 -68
  23. package/template/skills/github-npm-ops/references/preflight-checklist.md +1 -1
  24. package/template/skills/source-code-zip/SKILL.md +568 -0
  25. package/template/skills/source-code-zip/scripts/zip_source_code.js +1363 -0
  26. package/template/workflows/person/runtime-contract.json +9 -0
  27. package/template/workflows/specdev/I-init-setup/I-init-setup.md +1 -1
  28. package/template/workflows/specdev/INDEX.md +7 -8
  29. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +88 -27
  30. package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +121 -9
  31. package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +10 -7
  32. package/template/workflows/specdev/common/skills/subagent-delivery/references/source-package.md +229 -9
  33. package/template/workflows/specdev/runtime-contract.json +26 -0
  34. package/dist/src/migrations.d.ts +0 -23
  35. package/dist/src/migrations.js +0 -1202
  36. package/dist/src/migrations.js.map +0 -1
  37. package/template/commands/migrate-runtime-state.md +0 -43
  38. package/template/skills/migrate-runtime-state/SKILL.md +0 -93
  39. package/template/skills/migrate-runtime-state/references/migration-contract.md +0 -64
  40. package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +0 -916
  41. package/template/skills/source-code-zip-skill/SKILL.md +0 -343
  42. package/template/skills/source-code-zip-skill/scripts/zip_source_code.py +0 -638
  43. package/template/workflows/specdev/common/skills/subagent-delivery/references/github-checkpoints.md +0 -24
@@ -0,0 +1,1363 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * 将源码目录打包为 ZIP,并通过正则规则排除依赖、构建产物、秘密文件和非代码文件。
6
+ *
7
+ * 首选运行方式:
8
+ * node zip_source_code.js /path/to/project
9
+ *
10
+ * 本脚本只使用 Node.js 内置模块,不需要 npm install,也不依赖系统 zip 命令。
11
+ * 建议使用 Node.js 14 或更高版本。
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const crypto = require('crypto');
18
+ const zlib = require('zlib');
19
+ const { Transform, Writable, pipeline: pipelineCallback } = require('stream');
20
+ const { promisify } = require('util');
21
+
22
+ const pipeline = promisify(pipelineCallback);
23
+ const VERSION = '2.0.0';
24
+ const MIN_NODE_MAJOR = 14;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // 可编辑配置
28
+ // ---------------------------------------------------------------------------
29
+ // IGNORE 是默认忽略字段。每一项都是 JavaScript 正则表达式字符串,匹配对象是:
30
+ // 1. 相对于待压缩目录的路径;
31
+ // 2. 路径分隔符统一为 "/";
32
+ // 3. 路径开头没有 "/";
33
+ // 4. 正则匹配默认不区分大小写,并使用 RegExp.test。
34
+ //
35
+ // 示例:
36
+ // String.raw`(^|/)node_modules(?:/|$)` -> 忽略任意层级的 node_modules 目录
37
+ // String.raw`\.zip$` -> 忽略所有 .zip 文件
38
+ // String.raw`(^|/)private/` -> 忽略任意层级名为 private 的目录
39
+ const IGNORE = [
40
+ // 版本控制、编辑器和系统元数据
41
+ String.raw`(^|/)\.(?:git|hg|svn)(?:/|$)`,
42
+ String.raw`(^|/)(?:\.idea|\.vscode)(?:/|$)`,
43
+ String.raw`(^|/)(?:\.DS_Store|Thumbs\.db|desktop\.ini)$`,
44
+
45
+ // 依赖目录、虚拟环境和缓存
46
+ String.raw`(^|/)(?:node_modules?|bower_components|jspm_packages|vendor|Pods|Carthage)(?:/|$)`,
47
+ String.raw`(^|/)(?:\.venv|venv|env|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|\.nox|\.cache)(?:/|$)`,
48
+
49
+ // 构建产物、覆盖率和框架缓存
50
+ String.raw`(^|/)(?:dist|build|target|out|coverage|htmlcov|\.next|\.nuxt|\.svelte-kit|\.parcel-cache|\.turbo|\.gradle)(?:/|$)`,
51
+ String.raw`\.(?:min|bundle)\.(?:js|css)$`,
52
+
53
+ // 环境变量、证书、密钥和常见凭据文件
54
+ String.raw`(^|/)\.env(?:\..*)?$`,
55
+ String.raw`(^|/)(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.pub)?$`,
56
+ String.raw`\.(?:pem|key|p12|pfx|jks|keystore|crt|cer|der)$`,
57
+
58
+ // 用户明确要求排除 YAML
59
+ String.raw`\.ya?ml$`,
60
+
61
+ // 压缩包、归档和编译产物
62
+ String.raw`\.(?:zip|7z|rar|tar|tgz|tbz2?|txz|gz|bz2|xz|zst|jar|war|ear|whl|egg)$`,
63
+ String.raw`\.(?:exe|dll|so(?:\.\d+)*|dylib|bin|o|obj|a|lib|class|py[co]|pyd|wasm)$`,
64
+
65
+ // 媒体、办公文档、数据库、日志和临时文件
66
+ String.raw`\.(?:png|jpe?g|gif|webp|bmp|tiff?|ico|heic|avif|mp[34]|m4[av]|wav|flac|ogg|mov|avi|mkv|webm)$`,
67
+ String.raw`\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp)$`,
68
+ String.raw`\.(?:sqlite3?|db|dump|log|tmp|temp|swp|swo|bak|old)$`,
69
+
70
+ // 锁文件通常不是源代码;pnpm-lock.yaml 同时也会被 YAML 规则匹配
71
+ String.raw`(^|/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.ya?ml|poetry\.lock|uv\.lock|Pipfile\.lock|composer\.lock|Gemfile\.lock)$`,
72
+ ];
73
+
74
+ // 默认“仅源码”模式允许的扩展名。扩展名统一使用小写并包含前导点。
75
+ const CODE_EXTENSIONS = new Set([
76
+ // Python / JavaScript / TypeScript
77
+ '.py', '.pyi', '.pyx', '.pxd', '.pxi',
78
+ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx',
79
+
80
+ // Web / UI
81
+ '.html', '.htm', '.css', '.scss', '.sass', '.less',
82
+ '.vue', '.svelte', '.astro', '.mdx',
83
+
84
+ // JVM / Android
85
+ '.java', '.kt', '.kts', '.scala', '.groovy', '.gradle',
86
+
87
+ // Native / systems
88
+ '.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hxx',
89
+ '.m', '.mm', '.swift', '.rs', '.go', '.zig', '.nim',
90
+ '.asm', '.s', '.v', '.sv', '.svh', '.vhd', '.vhdl',
91
+
92
+ // .NET
93
+ '.cs', '.fs', '.fsx', '.fsi', '.vb',
94
+
95
+ // 脚本和命令行
96
+ '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd',
97
+ '.rb', '.php', '.pl', '.pm', '.lua', '.tcl',
98
+
99
+ // 函数式语言和其他语言
100
+ '.ex', '.exs', '.erl', '.hrl', '.clj', '.cljs', '.cljc',
101
+ '.hs', '.lhs', '.ml', '.mli', '.r', '.jl', '.cr',
102
+ '.dart', '.sol', '.move',
103
+
104
+ // 数据库、接口描述和基础设施即代码
105
+ '.sql', '.proto', '.graphql', '.gql', '.thrift',
106
+ '.tf', '.hcl', '.nix',
107
+
108
+ // 构建脚本和模板源码
109
+ '.cmake', '.mk', '.mak', '.mustache', '.hbs', '.ejs',
110
+ '.jinja', '.jinja2', '.j2', '.twig', '.tex',
111
+ ]);
112
+
113
+ // 无扩展名或特殊命名的常见源码/构建脚本。比较时不区分大小写。
114
+ const CODE_FILENAMES = new Set([
115
+ 'dockerfile',
116
+ 'containerfile',
117
+ 'makefile',
118
+ 'gnumakefile',
119
+ 'cmakelists.txt',
120
+ 'jenkinsfile',
121
+ 'vagrantfile',
122
+ 'rakefile',
123
+ 'gemfile',
124
+ 'procfile',
125
+ 'justfile',
126
+ 'meson.build',
127
+ 'meson_options.txt',
128
+ 'build',
129
+ 'workspace',
130
+ 'build.bazel',
131
+ 'workspace.bazel',
132
+ 'module.bazel',
133
+ ]);
134
+
135
+ const MAX_UINT16 = 0xffff;
136
+ const MAX_UINT32 = 0xffffffffn;
137
+ const ZIP_UTF8_FLAG = 0x0800;
138
+ const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008;
139
+ const ZIP_FLAGS = ZIP_UTF8_FLAG | ZIP_DATA_DESCRIPTOR_FLAG;
140
+
141
+ let activeTempPath = null;
142
+ let activeFileDescriptor = null;
143
+
144
+ class ZipSourceError extends Error {
145
+ constructor(message) {
146
+ super(message);
147
+ this.name = 'ZipSourceError';
148
+ }
149
+ }
150
+
151
+ function assertSupportedNode() {
152
+ const major = Number.parseInt(process.versions.node.split('.')[0], 10);
153
+ if (!Number.isInteger(major) || major < MIN_NODE_MAJOR) {
154
+ throw new ZipSourceError(
155
+ `需要 Node.js ${MIN_NODE_MAJOR} 或更高版本;当前版本是 ${process.versions.node}`,
156
+ );
157
+ }
158
+ }
159
+
160
+ function expandUser(inputPath) {
161
+ if (inputPath === '~') {
162
+ return os.homedir();
163
+ }
164
+ if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
165
+ return path.join(os.homedir(), inputPath.slice(2));
166
+ }
167
+ return inputPath;
168
+ }
169
+
170
+ function normalizedRelative(relativePath) {
171
+ return relativePath.split(path.sep).join('/');
172
+ }
173
+
174
+ function compareNames(left, right) {
175
+ if (left < right) return -1;
176
+ if (left > right) return 1;
177
+ return 0;
178
+ }
179
+
180
+ function samePath(left, right) {
181
+ const leftResolved = path.resolve(left);
182
+ const rightResolved = path.resolve(right);
183
+ if (process.platform === 'win32') {
184
+ return leftResolved.toLowerCase() === rightResolved.toLowerCase();
185
+ }
186
+ return leftResolved === rightResolved;
187
+ }
188
+
189
+ function archiveRootName(source) {
190
+ let name = path.basename(source);
191
+ if (!name) {
192
+ name = path.parse(source).root.replace(/[:/\\]+/g, '');
193
+ }
194
+ name = name.replace(/[/\\]+/g, '_');
195
+ return name || 'root';
196
+ }
197
+
198
+ function defaultOutputPath(source) {
199
+ return path.join(path.dirname(source), `${archiveRootName(source)}.code.zip`);
200
+ }
201
+
202
+ function normalizeOutputPath(outputPath) {
203
+ let resolved = path.resolve(expandUser(outputPath));
204
+ if (path.extname(resolved).toLowerCase() !== '.zip') {
205
+ resolved += '.zip';
206
+ }
207
+ return resolved;
208
+ }
209
+
210
+ function formatHumanSize(size) {
211
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
212
+ let divisor = 1n;
213
+ let unitIndex = 0;
214
+ while (unitIndex < units.length - 1 && size >= divisor * 1024n) {
215
+ divisor *= 1024n;
216
+ unitIndex += 1;
217
+ }
218
+
219
+ if (unitIndex === 0) {
220
+ return `${size.toString()} B`;
221
+ }
222
+
223
+ const hundredths = (size * 100n + divisor / 2n) / divisor;
224
+ const whole = hundredths / 100n;
225
+ const fraction = (hundredths % 100n).toString().padStart(2, '0');
226
+ return `${whole.toString()}.${fraction} ${units[unitIndex]}`;
227
+ }
228
+
229
+ function compileRules(patterns, label) {
230
+ return patterns.map((pattern) => {
231
+ try {
232
+ return { source: pattern, compiled: new RegExp(pattern, 'i') };
233
+ } catch (error) {
234
+ throw new ZipSourceError(`${label} 中存在无效正则 ${JSON.stringify(pattern)}: ${error.message}`);
235
+ }
236
+ });
237
+ }
238
+
239
+ function firstMatch(rules, relativePath) {
240
+ for (const rule of rules) {
241
+ if (rule.compiled.test(relativePath)) {
242
+ return rule;
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ function loadRegexFile(filePath) {
249
+ let text;
250
+ try {
251
+ text = fs.readFileSync(filePath, 'utf8');
252
+ } catch (error) {
253
+ throw new ZipSourceError(`无法读取忽略规则文件 ${filePath}: ${error.message}`);
254
+ }
255
+
256
+ const patterns = [];
257
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
258
+ for (let index = 0; index < lines.length; index += 1) {
259
+ const line = lines[index].trim();
260
+ if (!line || line.startsWith('#')) {
261
+ continue;
262
+ }
263
+ try {
264
+ // 先在这里验证,以便准确报告文件中的行号。
265
+ // eslint-disable-next-line no-new
266
+ new RegExp(line, 'i');
267
+ } catch (error) {
268
+ throw new ZipSourceError(
269
+ `忽略规则文件 ${filePath} 第 ${index + 1} 行正则无效: ${error.message}`,
270
+ );
271
+ }
272
+ patterns.push(line);
273
+ }
274
+ return patterns;
275
+ }
276
+
277
+ function hasShebang(filePath) {
278
+ if (path.extname(filePath)) {
279
+ return false;
280
+ }
281
+
282
+ let descriptor;
283
+ try {
284
+ descriptor = fs.openSync(filePath, 'r');
285
+ const buffer = Buffer.alloc(2);
286
+ const bytesRead = fs.readSync(descriptor, buffer, 0, 2, 0);
287
+ return bytesRead === 2 && buffer[0] === 0x23 && buffer[1] === 0x21;
288
+ } catch (_error) {
289
+ return false;
290
+ } finally {
291
+ if (descriptor !== undefined) {
292
+ try {
293
+ fs.closeSync(descriptor);
294
+ } catch (_error) {
295
+ // 忽略关闭阶段错误;真正读取时会报告更明确的信息。
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ function isCodeFile(filePath) {
302
+ const name = path.basename(filePath).toLowerCase();
303
+ if (CODE_FILENAMES.has(name)) {
304
+ return true;
305
+ }
306
+ if (CODE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
307
+ return true;
308
+ }
309
+ return hasShebang(filePath);
310
+ }
311
+
312
+ function logSkip(verbose, relativePath, reason) {
313
+ if (verbose) {
314
+ process.stdout.write(`[跳过:${reason}] ${relativePath}\n`);
315
+ }
316
+ }
317
+
318
+ function createStats() {
319
+ return {
320
+ included: 0,
321
+ includedBytes: 0n,
322
+ ignored: 0,
323
+ nonCode: 0,
324
+ symlinks: 0,
325
+ outputFile: 0,
326
+ special: 0,
327
+ };
328
+ }
329
+
330
+ function safeLstat(filePath) {
331
+ try {
332
+ return fs.lstatSync(filePath);
333
+ } catch (error) {
334
+ throw new ZipSourceError(`无法读取文件信息 ${filePath}: ${error.message}`);
335
+ }
336
+ }
337
+
338
+ function scanSource(options) {
339
+ const {
340
+ source,
341
+ output,
342
+ ignoreRules,
343
+ includeRules,
344
+ codeOnly,
345
+ verbose,
346
+ } = options;
347
+
348
+ const candidates = [];
349
+ const stats = createStats();
350
+ const stack = [{ absolute: source, relative: '' }];
351
+
352
+ while (stack.length > 0) {
353
+ const current = stack.pop();
354
+ let entries;
355
+ try {
356
+ entries = fs.readdirSync(current.absolute, { withFileTypes: true });
357
+ } catch (error) {
358
+ throw new ZipSourceError(`扫描目录失败 ${current.absolute}: ${error.message}`);
359
+ }
360
+ entries.sort((left, right) => compareNames(left.name, right.name));
361
+
362
+ const directories = [];
363
+ for (const entry of entries) {
364
+ const absolutePath = path.join(current.absolute, entry.name);
365
+ const relativePath = current.relative
366
+ ? path.join(current.relative, entry.name)
367
+ : entry.name;
368
+ const relativeText = normalizedRelative(relativePath);
369
+
370
+ if (entry.isSymbolicLink()) {
371
+ stats.symlinks += 1;
372
+ logSkip(verbose, relativeText, '符号链接');
373
+ continue;
374
+ }
375
+
376
+ if (entry.isDirectory()) {
377
+ const matched = firstMatch(ignoreRules, relativeText);
378
+ if (matched !== null) {
379
+ stats.ignored += 1;
380
+ logSkip(verbose, relativeText, `正则 ${matched.source}`);
381
+ continue;
382
+ }
383
+ directories.push({ absolute: absolutePath, relative: relativePath });
384
+ continue;
385
+ }
386
+
387
+ if (!entry.isFile()) {
388
+ // 某些文件系统的 Dirent 类型可能未知,再用 lstat 确认一次。
389
+ const stat = safeLstat(absolutePath);
390
+ if (stat.isSymbolicLink()) {
391
+ stats.symlinks += 1;
392
+ logSkip(verbose, relativeText, '符号链接');
393
+ continue;
394
+ }
395
+ if (stat.isDirectory()) {
396
+ const matched = firstMatch(ignoreRules, relativeText);
397
+ if (matched !== null) {
398
+ stats.ignored += 1;
399
+ logSkip(verbose, relativeText, `正则 ${matched.source}`);
400
+ continue;
401
+ }
402
+ directories.push({ absolute: absolutePath, relative: relativePath });
403
+ continue;
404
+ }
405
+ if (!stat.isFile()) {
406
+ stats.special += 1;
407
+ logSkip(verbose, relativeText, '非普通文件');
408
+ continue;
409
+ }
410
+ }
411
+
412
+ if (samePath(absolutePath, output)) {
413
+ stats.outputFile += 1;
414
+ logSkip(verbose, relativeText, '输出 ZIP 本身');
415
+ continue;
416
+ }
417
+
418
+ const matched = firstMatch(ignoreRules, relativeText);
419
+ if (matched !== null) {
420
+ stats.ignored += 1;
421
+ logSkip(verbose, relativeText, `正则 ${matched.source}`);
422
+ continue;
423
+ }
424
+
425
+ const explicitlyIncluded = firstMatch(includeRules, relativeText) !== null;
426
+ if (codeOnly && !explicitlyIncluded && !isCodeFile(absolutePath)) {
427
+ stats.nonCode += 1;
428
+ logSkip(verbose, relativeText, '非源码文件');
429
+ continue;
430
+ }
431
+
432
+ const stat = safeLstat(absolutePath);
433
+ if (stat.isSymbolicLink()) {
434
+ stats.symlinks += 1;
435
+ logSkip(verbose, relativeText, '符号链接');
436
+ continue;
437
+ }
438
+ if (!stat.isFile()) {
439
+ stats.special += 1;
440
+ logSkip(verbose, relativeText, '非普通文件');
441
+ continue;
442
+ }
443
+ if (!Number.isSafeInteger(stat.size) || stat.size < 0) {
444
+ throw new ZipSourceError(`文件大小超出 Node.js 可安全处理范围: ${absolutePath}`);
445
+ }
446
+
447
+ candidates.push({
448
+ absolutePath,
449
+ relativePath,
450
+ size: BigInt(stat.size),
451
+ });
452
+ stats.included += 1;
453
+ stats.includedBytes += BigInt(stat.size);
454
+ }
455
+
456
+ // 栈是后进先出,因此反向压入,保证遍历顺序稳定。
457
+ for (let index = directories.length - 1; index >= 0; index -= 1) {
458
+ stack.push(directories[index]);
459
+ }
460
+ }
461
+
462
+ candidates.sort((left, right) => (
463
+ compareNames(normalizedRelative(left.relativePath), normalizedRelative(right.relativePath))
464
+ ));
465
+ return { candidates, stats };
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // ZIP 写入实现(仅使用 Node.js 内置模块)
470
+ // ---------------------------------------------------------------------------
471
+
472
+ const CRC32_TABLE = (() => {
473
+ const table = new Uint32Array(256);
474
+ for (let index = 0; index < 256; index += 1) {
475
+ let value = index;
476
+ for (let bit = 0; bit < 8; bit += 1) {
477
+ value = (value & 1) !== 0
478
+ ? (0xedb88320 ^ (value >>> 1))
479
+ : (value >>> 1);
480
+ }
481
+ table[index] = value >>> 0;
482
+ }
483
+ return table;
484
+ })();
485
+
486
+ function crc32Update(crc, chunk) {
487
+ let value = crc >>> 0;
488
+ for (let index = 0; index < chunk.length; index += 1) {
489
+ value = CRC32_TABLE[(value ^ chunk[index]) & 0xff] ^ (value >>> 8);
490
+ }
491
+ return value >>> 0;
492
+ }
493
+
494
+ class CrcCounter extends Transform {
495
+ constructor() {
496
+ super();
497
+ this.crc = 0xffffffff;
498
+ this.size = 0n;
499
+ }
500
+
501
+ _transform(chunk, encoding, callback) {
502
+ try {
503
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
504
+ this.crc = crc32Update(this.crc, buffer);
505
+ this.size += BigInt(buffer.length);
506
+ callback(null, buffer);
507
+ } catch (error) {
508
+ callback(error);
509
+ }
510
+ }
511
+
512
+ digest() {
513
+ return (this.crc ^ 0xffffffff) >>> 0;
514
+ }
515
+ }
516
+
517
+ class ArchiveWriter {
518
+ constructor(fileDescriptor) {
519
+ this.fileDescriptor = fileDescriptor;
520
+ this.position = 0n;
521
+ }
522
+
523
+ write(buffer) {
524
+ let offset = 0;
525
+ while (offset < buffer.length) {
526
+ const written = fs.writeSync(
527
+ this.fileDescriptor,
528
+ buffer,
529
+ offset,
530
+ buffer.length - offset,
531
+ null,
532
+ );
533
+ if (written <= 0) {
534
+ throw new ZipSourceError('写入 ZIP 时没有取得进展');
535
+ }
536
+ offset += written;
537
+ this.position += BigInt(written);
538
+ }
539
+ }
540
+ }
541
+
542
+ class ArchiveWritable extends Writable {
543
+ constructor(archiveWriter) {
544
+ super();
545
+ this.archiveWriter = archiveWriter;
546
+ this.bytesWritten = 0n;
547
+ }
548
+
549
+ _write(chunk, encoding, callback) {
550
+ try {
551
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
552
+ this.archiveWriter.write(buffer);
553
+ this.bytesWritten += BigInt(buffer.length);
554
+ callback();
555
+ } catch (error) {
556
+ callback(error);
557
+ }
558
+ }
559
+ }
560
+
561
+ function toDosDateTime(date) {
562
+ let value = date instanceof Date && Number.isFinite(date.getTime()) ? date : new Date();
563
+ const year = value.getFullYear();
564
+ if (year < 1980) {
565
+ value = new Date(1980, 0, 1, 0, 0, 0);
566
+ } else if (year > 2107) {
567
+ value = new Date(2107, 11, 31, 23, 59, 58);
568
+ }
569
+
570
+ const dosTime = (
571
+ (value.getHours() << 11)
572
+ | (value.getMinutes() << 5)
573
+ | Math.floor(value.getSeconds() / 2)
574
+ ) & 0xffff;
575
+ const dosDate = (
576
+ ((value.getFullYear() - 1980) << 9)
577
+ | ((value.getMonth() + 1) << 5)
578
+ | value.getDate()
579
+ ) & 0xffff;
580
+ return { dosTime, dosDate };
581
+ }
582
+
583
+ function deflateBound(sourceSize) {
584
+ // 与 zlib 的保守上界公式一致,便于在写本地头之前判断是否需要 ZIP64。
585
+ return sourceSize
586
+ + (sourceSize >> 12n)
587
+ + (sourceSize >> 14n)
588
+ + (sourceSize >> 25n)
589
+ + 13n;
590
+ }
591
+
592
+ function makeZip64Extra(values) {
593
+ const payloadLength = values.length * 8;
594
+ const extra = Buffer.alloc(4 + payloadLength);
595
+ extra.writeUInt16LE(0x0001, 0);
596
+ extra.writeUInt16LE(payloadLength, 2);
597
+ for (let index = 0; index < values.length; index += 1) {
598
+ extra.writeBigUInt64LE(values[index], 4 + index * 8);
599
+ }
600
+ return extra;
601
+ }
602
+
603
+ function makeLocalHeader(entry) {
604
+ const extra = entry.useZip64
605
+ ? makeZip64Extra([0n, 0n])
606
+ : Buffer.alloc(0);
607
+ const header = Buffer.alloc(30 + entry.nameBuffer.length + extra.length);
608
+
609
+ header.writeUInt32LE(0x04034b50, 0);
610
+ header.writeUInt16LE(entry.useZip64 ? 45 : 20, 4);
611
+ header.writeUInt16LE(ZIP_FLAGS, 6);
612
+ header.writeUInt16LE(entry.method, 8);
613
+ header.writeUInt16LE(entry.dosTime, 10);
614
+ header.writeUInt16LE(entry.dosDate, 12);
615
+ header.writeUInt32LE(0, 14);
616
+ header.writeUInt32LE(entry.useZip64 ? 0xffffffff : 0, 18);
617
+ header.writeUInt32LE(entry.useZip64 ? 0xffffffff : 0, 22);
618
+ header.writeUInt16LE(entry.nameBuffer.length, 26);
619
+ header.writeUInt16LE(extra.length, 28);
620
+ entry.nameBuffer.copy(header, 30);
621
+ extra.copy(header, 30 + entry.nameBuffer.length);
622
+ return header;
623
+ }
624
+
625
+ function makeDataDescriptor(entry) {
626
+ if (entry.useZip64) {
627
+ const descriptor = Buffer.alloc(24);
628
+ descriptor.writeUInt32LE(0x08074b50, 0);
629
+ descriptor.writeUInt32LE(entry.crc32, 4);
630
+ descriptor.writeBigUInt64LE(entry.compressedSize, 8);
631
+ descriptor.writeBigUInt64LE(entry.uncompressedSize, 16);
632
+ return descriptor;
633
+ }
634
+
635
+ if (entry.compressedSize > MAX_UINT32 || entry.uncompressedSize > MAX_UINT32) {
636
+ throw new ZipSourceError(`文件在压缩过程中超过普通 ZIP 的 4 GiB 限制: ${entry.archiveName}`);
637
+ }
638
+ const descriptor = Buffer.alloc(16);
639
+ descriptor.writeUInt32LE(0x08074b50, 0);
640
+ descriptor.writeUInt32LE(entry.crc32, 4);
641
+ descriptor.writeUInt32LE(Number(entry.compressedSize), 8);
642
+ descriptor.writeUInt32LE(Number(entry.uncompressedSize), 12);
643
+ return descriptor;
644
+ }
645
+
646
+ function makeCentralHeader(entry) {
647
+ const needsZip64Sizes = (
648
+ entry.useZip64
649
+ || entry.compressedSize > MAX_UINT32
650
+ || entry.uncompressedSize > MAX_UINT32
651
+ );
652
+ const needsZip64Offset = entry.localHeaderOffset > MAX_UINT32;
653
+ const zip64Values = [];
654
+ if (needsZip64Sizes) {
655
+ zip64Values.push(entry.uncompressedSize, entry.compressedSize);
656
+ }
657
+ if (needsZip64Offset) {
658
+ zip64Values.push(entry.localHeaderOffset);
659
+ }
660
+ const extra = zip64Values.length > 0 ? makeZip64Extra(zip64Values) : Buffer.alloc(0);
661
+ const versionNeeded = zip64Values.length > 0 || entry.useZip64 ? 45 : 20;
662
+ const header = Buffer.alloc(46 + entry.nameBuffer.length + extra.length);
663
+
664
+ header.writeUInt32LE(0x02014b50, 0);
665
+ header.writeUInt16LE(versionNeeded >= 45 ? 0x032d : 0x031e, 4); // Unix + ZIP 4.5/3.0
666
+ header.writeUInt16LE(versionNeeded, 6);
667
+ header.writeUInt16LE(ZIP_FLAGS, 8);
668
+ header.writeUInt16LE(entry.method, 10);
669
+ header.writeUInt16LE(entry.dosTime, 12);
670
+ header.writeUInt16LE(entry.dosDate, 14);
671
+ header.writeUInt32LE(entry.crc32, 16);
672
+ header.writeUInt32LE(needsZip64Sizes ? 0xffffffff : Number(entry.compressedSize), 20);
673
+ header.writeUInt32LE(needsZip64Sizes ? 0xffffffff : Number(entry.uncompressedSize), 24);
674
+ header.writeUInt16LE(entry.nameBuffer.length, 28);
675
+ header.writeUInt16LE(extra.length, 30);
676
+ header.writeUInt16LE(0, 32); // file comment length
677
+ header.writeUInt16LE(0, 34); // disk number start
678
+ header.writeUInt16LE(0, 36); // internal attributes
679
+ header.writeUInt32LE(((entry.mode & 0xffff) << 16) >>> 0, 38);
680
+ header.writeUInt32LE(needsZip64Offset ? 0xffffffff : Number(entry.localHeaderOffset), 42);
681
+ entry.nameBuffer.copy(header, 46);
682
+ extra.copy(header, 46 + entry.nameBuffer.length);
683
+ return header;
684
+ }
685
+
686
+ function makeZip64EndOfCentralDirectory(entryCount, centralSize, centralOffset) {
687
+ const record = Buffer.alloc(56);
688
+ record.writeUInt32LE(0x06064b50, 0);
689
+ record.writeBigUInt64LE(44n, 4);
690
+ record.writeUInt16LE(0x032d, 12); // version made by: Unix, ZIP 4.5
691
+ record.writeUInt16LE(45, 14);
692
+ record.writeUInt32LE(0, 16);
693
+ record.writeUInt32LE(0, 20);
694
+ record.writeBigUInt64LE(entryCount, 24);
695
+ record.writeBigUInt64LE(entryCount, 32);
696
+ record.writeBigUInt64LE(centralSize, 40);
697
+ record.writeBigUInt64LE(centralOffset, 48);
698
+ return record;
699
+ }
700
+
701
+ function makeZip64Locator(zip64RecordOffset) {
702
+ const locator = Buffer.alloc(20);
703
+ locator.writeUInt32LE(0x07064b50, 0);
704
+ locator.writeUInt32LE(0, 4);
705
+ locator.writeBigUInt64LE(zip64RecordOffset, 8);
706
+ locator.writeUInt32LE(1, 16);
707
+ return locator;
708
+ }
709
+
710
+ function makeEndOfCentralDirectory(entryCount, centralSize, centralOffset) {
711
+ const record = Buffer.alloc(22);
712
+ record.writeUInt32LE(0x06054b50, 0);
713
+ record.writeUInt16LE(0, 4);
714
+ record.writeUInt16LE(0, 6);
715
+ record.writeUInt16LE(entryCount >= BigInt(MAX_UINT16) ? MAX_UINT16 : Number(entryCount), 8);
716
+ record.writeUInt16LE(entryCount >= BigInt(MAX_UINT16) ? MAX_UINT16 : Number(entryCount), 10);
717
+ record.writeUInt32LE(centralSize > MAX_UINT32 ? 0xffffffff : Number(centralSize), 12);
718
+ record.writeUInt32LE(centralOffset > MAX_UINT32 ? 0xffffffff : Number(centralOffset), 16);
719
+ record.writeUInt16LE(0, 20);
720
+ return record;
721
+ }
722
+
723
+ async function streamFileToArchive(filePath, writer, method, compressionLevel) {
724
+ const counter = new CrcCounter();
725
+ const sink = new ArchiveWritable(writer);
726
+ const noFollowFlag = typeof fs.constants.O_NOFOLLOW === 'number'
727
+ ? fs.constants.O_NOFOLLOW
728
+ : 0;
729
+
730
+ let inputDescriptor;
731
+ try {
732
+ inputDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollowFlag);
733
+ } catch (error) {
734
+ throw new ZipSourceError(`无法打开文件 ${filePath}: ${error.message}`);
735
+ }
736
+
737
+ const input = fs.createReadStream(filePath, {
738
+ fd: inputDescriptor,
739
+ autoClose: true,
740
+ highWaterMark: 1024 * 1024,
741
+ });
742
+
743
+ try {
744
+ if (method === 8) {
745
+ const deflater = zlib.createDeflateRaw({ level: compressionLevel });
746
+ await pipeline(input, counter, deflater, sink);
747
+ } else {
748
+ await pipeline(input, counter, sink);
749
+ }
750
+ } catch (error) {
751
+ throw new ZipSourceError(`读取或压缩文件失败 ${filePath}: ${error.message}`);
752
+ }
753
+
754
+ return {
755
+ crc32: counter.digest(),
756
+ uncompressedSize: counter.size,
757
+ compressedSize: sink.bytesWritten,
758
+ };
759
+ }
760
+
761
+ function createTempOutput(output) {
762
+ const parent = path.dirname(output);
763
+ try {
764
+ fs.mkdirSync(parent, { recursive: true });
765
+ } catch (error) {
766
+ throw new ZipSourceError(`无法创建输出目录 ${parent}: ${error.message}`);
767
+ }
768
+
769
+ for (let attempt = 0; attempt < 20; attempt += 1) {
770
+ const token = crypto.randomBytes(8).toString('hex');
771
+ const candidate = path.join(parent, `.${path.basename(output)}.${process.pid}.${token}.tmp`);
772
+ try {
773
+ const descriptor = fs.openSync(candidate, 'wx', 0o600);
774
+ return { tempPath: candidate, descriptor };
775
+ } catch (error) {
776
+ if (error.code !== 'EEXIST') {
777
+ throw new ZipSourceError(`无法创建临时 ZIP ${candidate}: ${error.message}`);
778
+ }
779
+ }
780
+ }
781
+ throw new ZipSourceError(`无法在 ${parent} 创建唯一的临时 ZIP`);
782
+ }
783
+
784
+ function outputPathState(output) {
785
+ try {
786
+ const stat = fs.statSync(output);
787
+ return { exists: true, isDirectory: stat.isDirectory() };
788
+ } catch (error) {
789
+ if (error.code === 'ENOENT') {
790
+ return { exists: false, isDirectory: false };
791
+ }
792
+ throw new ZipSourceError(`无法检查输出路径 ${output}: ${error.message}`);
793
+ }
794
+ }
795
+
796
+ function installTempOutput(tempPath, output, force) {
797
+ if (force) {
798
+ try {
799
+ fs.renameSync(tempPath, output);
800
+ return;
801
+ } catch (error) {
802
+ const replaceCodes = new Set(['EEXIST', 'EPERM', 'EACCES', 'ENOTEMPTY']);
803
+ if (!replaceCodes.has(error.code)) {
804
+ throw new ZipSourceError(`无法写入最终 ZIP ${output}: ${error.message}`);
805
+ }
806
+ }
807
+
808
+ try {
809
+ const state = outputPathState(output);
810
+ if (state.isDirectory) {
811
+ throw new ZipSourceError(`输出路径是目录,不是 ZIP 文件: ${output}`);
812
+ }
813
+ if (state.exists) {
814
+ fs.unlinkSync(output);
815
+ }
816
+ fs.renameSync(tempPath, output);
817
+ return;
818
+ } catch (error) {
819
+ if (error instanceof ZipSourceError) {
820
+ throw error;
821
+ }
822
+ throw new ZipSourceError(`无法覆盖最终 ZIP ${output}: ${error.message}`);
823
+ }
824
+ }
825
+
826
+ // 使用硬链接实现“目标不存在才安装”,避免普通 rename 在 POSIX 上悄悄覆盖竞态文件。
827
+ try {
828
+ fs.linkSync(tempPath, output);
829
+ fs.unlinkSync(tempPath);
830
+ return;
831
+ } catch (error) {
832
+ if (error.code === 'EEXIST') {
833
+ throw new ZipSourceError(`输出文件已存在: ${output};使用 --force 覆盖`);
834
+ }
835
+ if (!['EPERM', 'EACCES', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV'].includes(error.code)) {
836
+ throw new ZipSourceError(`无法写入最终 ZIP ${output}: ${error.message}`);
837
+ }
838
+ }
839
+
840
+ // 极少数文件系统不支持硬链接,回退为排他复制。
841
+ try {
842
+ fs.copyFileSync(tempPath, output, fs.constants.COPYFILE_EXCL);
843
+ fs.unlinkSync(tempPath);
844
+ } catch (error) {
845
+ if (error.code === 'EEXIST') {
846
+ throw new ZipSourceError(`输出文件已存在: ${output};使用 --force 覆盖`);
847
+ }
848
+ throw new ZipSourceError(`无法写入最终 ZIP ${output}: ${error.message}`);
849
+ }
850
+ }
851
+
852
+ function cleanupActiveTemp() {
853
+ if (activeFileDescriptor !== null) {
854
+ try {
855
+ fs.closeSync(activeFileDescriptor);
856
+ } catch (_error) {
857
+ // 忽略清理错误。
858
+ }
859
+ activeFileDescriptor = null;
860
+ }
861
+ if (activeTempPath !== null) {
862
+ try {
863
+ fs.unlinkSync(activeTempPath);
864
+ } catch (_error) {
865
+ // 忽略清理错误。
866
+ }
867
+ activeTempPath = null;
868
+ }
869
+ }
870
+
871
+ async function createArchive(options) {
872
+ const {
873
+ source,
874
+ output,
875
+ candidates,
876
+ compressionLevel,
877
+ contentsOnly,
878
+ force,
879
+ } = options;
880
+
881
+ const { tempPath, descriptor } = createTempOutput(output);
882
+ activeTempPath = tempPath;
883
+ activeFileDescriptor = descriptor;
884
+ const writer = new ArchiveWriter(descriptor);
885
+ const centralEntries = [];
886
+ const rootName = archiveRootName(source);
887
+
888
+ try {
889
+ for (const candidate of candidates) {
890
+ const stat = safeLstat(candidate.absolutePath);
891
+ if (stat.isSymbolicLink()) {
892
+ throw new ZipSourceError(`文件在扫描后变成了符号链接,已中止: ${candidate.absolutePath}`);
893
+ }
894
+ if (!stat.isFile()) {
895
+ throw new ZipSourceError(`文件在扫描后不再是普通文件,已中止: ${candidate.absolutePath}`);
896
+ }
897
+ if (!Number.isSafeInteger(stat.size) || stat.size < 0) {
898
+ throw new ZipSourceError(`文件大小超出 Node.js 可安全处理范围: ${candidate.absolutePath}`);
899
+ }
900
+
901
+ const relativeText = normalizedRelative(candidate.relativePath);
902
+ const archiveName = contentsOnly ? relativeText : `${rootName}/${relativeText}`;
903
+ const nameBuffer = Buffer.from(archiveName, 'utf8');
904
+ if (nameBuffer.length === 0 || nameBuffer.length > MAX_UINT16) {
905
+ throw new ZipSourceError(`ZIP 内部路径过长或为空: ${archiveName}`);
906
+ }
907
+
908
+ const sizeHint = BigInt(stat.size);
909
+ const method = compressionLevel === 0 ? 0 : 8;
910
+ const useZip64 = sizeHint > MAX_UINT32
911
+ || (method === 8 && deflateBound(sizeHint) > MAX_UINT32);
912
+ const { dosTime, dosDate } = toDosDateTime(stat.mtime);
913
+ const localHeaderOffset = writer.position;
914
+ const baseEntry = {
915
+ archiveName,
916
+ nameBuffer,
917
+ method,
918
+ dosTime,
919
+ dosDate,
920
+ mode: stat.mode,
921
+ useZip64,
922
+ };
923
+
924
+ writer.write(makeLocalHeader(baseEntry));
925
+ const streamed = await streamFileToArchive(
926
+ candidate.absolutePath,
927
+ writer,
928
+ method,
929
+ compressionLevel,
930
+ );
931
+
932
+ const completeEntry = {
933
+ ...baseEntry,
934
+ ...streamed,
935
+ localHeaderOffset,
936
+ };
937
+ writer.write(makeDataDescriptor(completeEntry));
938
+ centralEntries.push(completeEntry);
939
+ }
940
+
941
+ const centralOffset = writer.position;
942
+ let anyZip64Entry = false;
943
+ for (const entry of centralEntries) {
944
+ if (
945
+ entry.useZip64
946
+ || entry.compressedSize > MAX_UINT32
947
+ || entry.uncompressedSize > MAX_UINT32
948
+ || entry.localHeaderOffset > MAX_UINT32
949
+ ) {
950
+ anyZip64Entry = true;
951
+ }
952
+ writer.write(makeCentralHeader(entry));
953
+ }
954
+ const centralSize = writer.position - centralOffset;
955
+ const entryCount = BigInt(centralEntries.length);
956
+ const needsZip64End = (
957
+ anyZip64Entry
958
+ || entryCount >= BigInt(MAX_UINT16)
959
+ || centralSize > MAX_UINT32
960
+ || centralOffset > MAX_UINT32
961
+ );
962
+
963
+ if (needsZip64End) {
964
+ const zip64RecordOffset = writer.position;
965
+ writer.write(makeZip64EndOfCentralDirectory(entryCount, centralSize, centralOffset));
966
+ writer.write(makeZip64Locator(zip64RecordOffset));
967
+ }
968
+ writer.write(makeEndOfCentralDirectory(entryCount, centralSize, centralOffset));
969
+
970
+ fs.fsyncSync(descriptor);
971
+ fs.closeSync(descriptor);
972
+ activeFileDescriptor = null;
973
+
974
+ installTempOutput(tempPath, output, force);
975
+ activeTempPath = null;
976
+ } catch (error) {
977
+ cleanupActiveTemp();
978
+ if (error instanceof ZipSourceError) {
979
+ throw error;
980
+ }
981
+ throw new ZipSourceError(`创建 ZIP 失败: ${error.message}`);
982
+ }
983
+ }
984
+
985
+ // ---------------------------------------------------------------------------
986
+ // 命令行
987
+ // ---------------------------------------------------------------------------
988
+
989
+ function printDefaults() {
990
+ process.stdout.write('默认 IGNORE 正则:\n');
991
+ for (const pattern of IGNORE) {
992
+ process.stdout.write(` ${pattern}\n`);
993
+ }
994
+ process.stdout.write('\n默认源码扩展名:\n');
995
+ process.stdout.write(` ${Array.from(CODE_EXTENSIONS).sort(compareNames).join(' ')}\n`);
996
+ process.stdout.write('\n默认特殊源码文件名:\n');
997
+ process.stdout.write(` ${Array.from(CODE_FILENAMES).sort(compareNames).join(' ')}\n`);
998
+ }
999
+
1000
+ function printHelp(programName) {
1001
+ const help = `将指定目录压缩为只包含源码的 ZIP。
1002
+ 默认使用正则排除依赖、构建产物、秘密文件、YAML 和压缩包。
1003
+
1004
+ 用法:
1005
+ node ${programName} [选项] <目录>
1006
+
1007
+ 参数:
1008
+ <目录> 要压缩的源码目录
1009
+
1010
+ 选项:
1011
+ -o, --output <ZIP> 输出 ZIP;默认是 <目录名>.code.zip
1012
+ -f, --force 允许覆盖已存在的输出 ZIP
1013
+ --ignore <REGEX> 追加忽略正则;可重复使用
1014
+ --ignore-from <FILE> 从 UTF-8 文件读取忽略正则,每行一个
1015
+ --include <REGEX> 在仅源码模式下额外纳入匹配文件
1016
+ --all-files 纳入所有未被 IGNORE 排除的普通文件
1017
+ --no-default-ignore 禁用脚本内置 IGNORE;请谨慎使用
1018
+ --contents-only ZIP 内不创建顶层目录
1019
+ --compression-level <0-9> 0 为仅存储,1 最快,9 压缩率最高;默认 9
1020
+ --dry-run 只显示将被纳入的文件,不创建 ZIP
1021
+ --allow-empty 允许创建空 ZIP
1022
+ -v, --verbose 显示每个被跳过项目的原因
1023
+ -q, --quiet 成功时仅输出 ZIP 的绝对路径
1024
+ --show-defaults 显示内置规则后退出
1025
+ --version 显示版本后退出
1026
+ -h, --help 显示本帮助
1027
+
1028
+ 示例:
1029
+ node ${programName} ./my-project
1030
+ node ${programName} ./my-project -o ./backup/project.zip --force
1031
+ node ${programName} ./my-project --dry-run --verbose
1032
+ node ${programName} ./my-project --ignore '(^|/)fixtures?(/|$)'
1033
+ node ${programName} ./my-project --include '(^|/)package\\.json$'
1034
+
1035
+ 注意:
1036
+ --ignore、--include 和 IGNORE 使用 JavaScript 正则表达式,不是 .gitignore glob。
1037
+ 本脚本只使用 Node.js 内置模块,不需要 npm install,也不调用系统 zip 命令。
1038
+ `;
1039
+ process.stdout.write(help);
1040
+ }
1041
+
1042
+ function parseArguments(argv) {
1043
+ const options = {
1044
+ directory: null,
1045
+ output: null,
1046
+ force: false,
1047
+ ignore: [],
1048
+ ignoreFrom: [],
1049
+ include: [],
1050
+ allFiles: false,
1051
+ noDefaultIgnore: false,
1052
+ contentsOnly: false,
1053
+ compressionLevel: 9,
1054
+ dryRun: false,
1055
+ allowEmpty: false,
1056
+ verbose: false,
1057
+ quiet: false,
1058
+ showDefaults: false,
1059
+ showVersion: false,
1060
+ showHelp: false,
1061
+ };
1062
+ const positionals = [];
1063
+ let endOfOptions = false;
1064
+
1065
+ function requireValue(optionName, inlineValue, indexRef) {
1066
+ if (inlineValue !== null) {
1067
+ if (inlineValue.length === 0) {
1068
+ throw new ZipSourceError(`选项 ${optionName} 缺少值`);
1069
+ }
1070
+ return { value: inlineValue, nextIndex: indexRef };
1071
+ }
1072
+ if (indexRef + 1 >= argv.length) {
1073
+ throw new ZipSourceError(`选项 ${optionName} 缺少值`);
1074
+ }
1075
+ return { value: argv[indexRef + 1], nextIndex: indexRef + 1 };
1076
+ }
1077
+
1078
+ function rejectInlineValue(optionName, inlineValue) {
1079
+ if (inlineValue !== null) {
1080
+ throw new ZipSourceError(`选项 ${optionName} 不接受值`);
1081
+ }
1082
+ }
1083
+
1084
+ for (let index = 0; index < argv.length; index += 1) {
1085
+ const argument = argv[index];
1086
+ if (endOfOptions) {
1087
+ positionals.push(argument);
1088
+ continue;
1089
+ }
1090
+ if (argument === '--') {
1091
+ endOfOptions = true;
1092
+ continue;
1093
+ }
1094
+
1095
+ if (argument.startsWith('--')) {
1096
+ const equalsIndex = argument.indexOf('=');
1097
+ const name = equalsIndex === -1 ? argument : argument.slice(0, equalsIndex);
1098
+ const inlineValue = equalsIndex === -1 ? null : argument.slice(equalsIndex + 1);
1099
+
1100
+ switch (name) {
1101
+ case '--output': {
1102
+ const result = requireValue(name, inlineValue, index);
1103
+ options.output = result.value;
1104
+ index = result.nextIndex;
1105
+ break;
1106
+ }
1107
+ case '--ignore': {
1108
+ const result = requireValue(name, inlineValue, index);
1109
+ options.ignore.push(result.value);
1110
+ index = result.nextIndex;
1111
+ break;
1112
+ }
1113
+ case '--ignore-from': {
1114
+ const result = requireValue(name, inlineValue, index);
1115
+ options.ignoreFrom.push(result.value);
1116
+ index = result.nextIndex;
1117
+ break;
1118
+ }
1119
+ case '--include': {
1120
+ const result = requireValue(name, inlineValue, index);
1121
+ options.include.push(result.value);
1122
+ index = result.nextIndex;
1123
+ break;
1124
+ }
1125
+ case '--compression-level': {
1126
+ const result = requireValue(name, inlineValue, index);
1127
+ if (!/^[0-9]$/.test(result.value)) {
1128
+ throw new ZipSourceError('--compression-level 必须是 0 到 9 的整数');
1129
+ }
1130
+ options.compressionLevel = Number(result.value);
1131
+ index = result.nextIndex;
1132
+ break;
1133
+ }
1134
+ case '--force':
1135
+ rejectInlineValue(name, inlineValue);
1136
+ options.force = true;
1137
+ break;
1138
+ case '--all-files':
1139
+ rejectInlineValue(name, inlineValue);
1140
+ options.allFiles = true;
1141
+ break;
1142
+ case '--no-default-ignore':
1143
+ rejectInlineValue(name, inlineValue);
1144
+ options.noDefaultIgnore = true;
1145
+ break;
1146
+ case '--contents-only':
1147
+ rejectInlineValue(name, inlineValue);
1148
+ options.contentsOnly = true;
1149
+ break;
1150
+ case '--dry-run':
1151
+ rejectInlineValue(name, inlineValue);
1152
+ options.dryRun = true;
1153
+ break;
1154
+ case '--allow-empty':
1155
+ rejectInlineValue(name, inlineValue);
1156
+ options.allowEmpty = true;
1157
+ break;
1158
+ case '--verbose':
1159
+ rejectInlineValue(name, inlineValue);
1160
+ options.verbose = true;
1161
+ break;
1162
+ case '--quiet':
1163
+ rejectInlineValue(name, inlineValue);
1164
+ options.quiet = true;
1165
+ break;
1166
+ case '--show-defaults':
1167
+ rejectInlineValue(name, inlineValue);
1168
+ options.showDefaults = true;
1169
+ break;
1170
+ case '--version':
1171
+ rejectInlineValue(name, inlineValue);
1172
+ options.showVersion = true;
1173
+ break;
1174
+ case '--help':
1175
+ rejectInlineValue(name, inlineValue);
1176
+ options.showHelp = true;
1177
+ break;
1178
+ default:
1179
+ throw new ZipSourceError(`未知选项: ${name}`);
1180
+ }
1181
+ continue;
1182
+ }
1183
+
1184
+ switch (argument) {
1185
+ case '-o': {
1186
+ const result = requireValue(argument, null, index);
1187
+ options.output = result.value;
1188
+ index = result.nextIndex;
1189
+ break;
1190
+ }
1191
+ case '-f':
1192
+ options.force = true;
1193
+ break;
1194
+ case '-v':
1195
+ options.verbose = true;
1196
+ break;
1197
+ case '-q':
1198
+ options.quiet = true;
1199
+ break;
1200
+ case '-h':
1201
+ options.showHelp = true;
1202
+ break;
1203
+ default:
1204
+ if (argument.startsWith('-')) {
1205
+ throw new ZipSourceError(`未知选项: ${argument};路径以 - 开头时请放在 -- 后面`);
1206
+ }
1207
+ positionals.push(argument);
1208
+ break;
1209
+ }
1210
+ }
1211
+
1212
+ if (positionals.length > 1) {
1213
+ throw new ZipSourceError(`只能指定一个目录,收到: ${positionals.join(', ')}`);
1214
+ }
1215
+ options.directory = positionals.length === 1 ? positionals[0] : null;
1216
+
1217
+ if (options.verbose && options.quiet) {
1218
+ throw new ZipSourceError('--verbose 与 --quiet 不能同时使用');
1219
+ }
1220
+ return options;
1221
+ }
1222
+
1223
+ async function run(argv) {
1224
+ assertSupportedNode();
1225
+ const args = parseArguments(argv);
1226
+ const programName = path.basename(process.argv[1] || 'zip_source_code.js');
1227
+
1228
+ if (args.showHelp) {
1229
+ printHelp(programName);
1230
+ return 0;
1231
+ }
1232
+ if (args.showVersion) {
1233
+ process.stdout.write(`${programName} ${VERSION}\n`);
1234
+ return 0;
1235
+ }
1236
+ if (args.showDefaults) {
1237
+ printDefaults();
1238
+ return 0;
1239
+ }
1240
+ if (args.directory === null) {
1241
+ throw new ZipSourceError(`缺少要压缩的目录;使用 node ${programName} --help 查看帮助`);
1242
+ }
1243
+
1244
+ const source = path.resolve(expandUser(args.directory));
1245
+ let sourceStat;
1246
+ try {
1247
+ sourceStat = fs.statSync(source);
1248
+ } catch (error) {
1249
+ if (error.code === 'ENOENT') {
1250
+ throw new ZipSourceError(`目录不存在: ${source}`);
1251
+ }
1252
+ throw new ZipSourceError(`无法访问目录 ${source}: ${error.message}`);
1253
+ }
1254
+ if (!sourceStat.isDirectory()) {
1255
+ throw new ZipSourceError(`参数不是目录: ${source}`);
1256
+ }
1257
+
1258
+ const output = args.output
1259
+ ? normalizeOutputPath(args.output)
1260
+ : path.resolve(defaultOutputPath(source));
1261
+ const initialOutputState = outputPathState(output);
1262
+ if (initialOutputState.isDirectory) {
1263
+ throw new ZipSourceError(`输出路径是目录,不是 ZIP 文件: ${output}`);
1264
+ }
1265
+ if (initialOutputState.exists && !args.force && !args.dryRun) {
1266
+ throw new ZipSourceError(`输出文件已存在: ${output};使用 --force 覆盖`);
1267
+ }
1268
+
1269
+ const ignorePatterns = args.noDefaultIgnore ? [] : IGNORE.slice();
1270
+ ignorePatterns.push(...args.ignore);
1271
+ for (const regexFileInput of args.ignoreFrom) {
1272
+ const regexFile = path.resolve(expandUser(regexFileInput));
1273
+ ignorePatterns.push(...loadRegexFile(regexFile));
1274
+ }
1275
+
1276
+ const ignoreRules = compileRules(ignorePatterns, '忽略规则');
1277
+ const includeRules = compileRules(args.include, '额外纳入规则');
1278
+ const { candidates, stats } = scanSource({
1279
+ source,
1280
+ output,
1281
+ ignoreRules,
1282
+ includeRules,
1283
+ codeOnly: !args.allFiles,
1284
+ verbose: args.verbose,
1285
+ });
1286
+
1287
+ if (args.dryRun && !args.quiet) {
1288
+ for (const candidate of candidates) {
1289
+ process.stdout.write(`[包含] ${normalizedRelative(candidate.relativePath)}\n`);
1290
+ }
1291
+ }
1292
+
1293
+ if (candidates.length === 0 && !args.allowEmpty) {
1294
+ throw new ZipSourceError(
1295
+ '没有找到可打包文件;可检查 IGNORE/源码扩展名,或使用 --all-files、--include、--allow-empty',
1296
+ );
1297
+ }
1298
+
1299
+ if (!args.quiet) {
1300
+ process.stdout.write(
1301
+ `扫描结果: 包含 ${stats.included} 个文件(${formatHumanSize(stats.includedBytes)});`
1302
+ + `正则忽略 ${stats.ignored} 项;`
1303
+ + `非源码 ${stats.nonCode} 项;`
1304
+ + `符号链接 ${stats.symlinks} 项;`
1305
+ + `非普通文件 ${stats.special} 项。\n`,
1306
+ );
1307
+ }
1308
+
1309
+ if (args.dryRun) {
1310
+ if (!args.quiet) {
1311
+ process.stdout.write('预览完成:未创建 ZIP。\n');
1312
+ }
1313
+ return 0;
1314
+ }
1315
+
1316
+ await createArchive({
1317
+ source,
1318
+ output,
1319
+ candidates,
1320
+ compressionLevel: args.compressionLevel,
1321
+ contentsOnly: args.contentsOnly,
1322
+ force: args.force,
1323
+ });
1324
+
1325
+ let archiveSize = 0n;
1326
+ try {
1327
+ const outputStat = fs.statSync(output);
1328
+ archiveSize = BigInt(outputStat.size);
1329
+ } catch (_error) {
1330
+ // ZIP 已成功安装;大小读取失败不影响结果。
1331
+ }
1332
+
1333
+ if (args.quiet) {
1334
+ process.stdout.write(`${output}\n`);
1335
+ } else {
1336
+ process.stdout.write(`已创建: ${output}\n`);
1337
+ process.stdout.write(`ZIP 大小: ${formatHumanSize(archiveSize)}\n`);
1338
+ }
1339
+ return 0;
1340
+ }
1341
+
1342
+ function handleSignal(signal) {
1343
+ cleanupActiveTemp();
1344
+ process.stderr.write(`\n操作已取消(${signal})。\n`);
1345
+ process.exit(130);
1346
+ }
1347
+
1348
+ process.once('SIGINT', () => handleSignal('SIGINT'));
1349
+ process.once('SIGTERM', () => handleSignal('SIGTERM'));
1350
+
1351
+ run(process.argv.slice(2))
1352
+ .then((code) => {
1353
+ process.exitCode = code;
1354
+ })
1355
+ .catch((error) => {
1356
+ cleanupActiveTemp();
1357
+ if (error instanceof ZipSourceError) {
1358
+ process.stderr.write(`错误: ${error.message}\n`);
1359
+ } else {
1360
+ process.stderr.write(`错误: ${error && error.stack ? error.stack : String(error)}\n`);
1361
+ }
1362
+ process.exitCode = 1;
1363
+ });