@king-3/file-kit 1.0.0-beta.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1067 @@
1
+ import process from "node:process";
2
+ import { confirm, intro, isCancel, outro, password, select, spinner, text } from "@clack/prompts";
3
+ import ansis, { bold, cyan, gray, yellow } from "ansis";
4
+ import { defineCommand, runMain } from "citty";
5
+ import path from "node:path";
6
+ import fs from "node:fs/promises";
7
+ import { Buffer } from "node:buffer";
8
+ import { accessSync, existsSync, mkdirSync } from "node:fs";
9
+ import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
10
+ import path$1 from "node:path/posix";
11
+ import ffmpegPath from "@ffmpeg-installer/ffmpeg";
12
+ import { execa } from "execa";
13
+
14
+ //#region src/utils/logger.ts
15
+ const logger = {
16
+ success: (msg) => console.log(`${ansis.green("✓")} ${msg}`),
17
+ error: (msg) => console.log(`${ansis.red("✗")} ${msg}`),
18
+ info: (msg) => console.log(`${ansis.cyan("ℹ")} ${msg}`),
19
+ warn: (msg) => console.log(`${ansis.yellow("⚠")} ${msg}`),
20
+ dim: (msg) => console.log(`${ansis.dim("•")} ${msg}`)
21
+ };
22
+
23
+ //#endregion
24
+ //#region src/utils/errors.ts
25
+ /**
26
+ * 应用错误基类
27
+ */
28
+ var AppError = class extends Error {
29
+ constructor(message, code, details) {
30
+ super(message);
31
+ this.code = code;
32
+ this.details = details;
33
+ this.name = "AppError";
34
+ Error.captureStackTrace(this, this.constructor);
35
+ }
36
+ };
37
+ /**
38
+ * 文件相关错误
39
+ */
40
+ var FileError = class extends AppError {
41
+ constructor(message, filePath) {
42
+ super(message, "FILE_ERROR", { filePath });
43
+ this.filePath = filePath;
44
+ this.name = "FileError";
45
+ }
46
+ };
47
+ /**
48
+ * 验证错误
49
+ */
50
+ var ValidationError = class extends AppError {
51
+ constructor(message, field) {
52
+ super(message, "VALIDATION_ERROR", { field });
53
+ this.field = field;
54
+ this.name = "ValidationError";
55
+ }
56
+ };
57
+ /**
58
+ * 转换错误
59
+ */
60
+ var ConversionError = class extends AppError {
61
+ constructor(message, operation) {
62
+ super(message, "CONVERSION_ERROR", { operation });
63
+ this.operation = operation;
64
+ this.name = "ConversionError";
65
+ }
66
+ };
67
+ /**
68
+ * 加密/解密错误
69
+ */
70
+ var CryptoError = class extends AppError {
71
+ constructor(message, operation) {
72
+ super(message, "CRYPTO_ERROR", { operation });
73
+ this.operation = operation;
74
+ this.name = "CryptoError";
75
+ }
76
+ };
77
+ /**
78
+ * FFmpeg 相关错误
79
+ */
80
+ var FFmpegError = class extends AppError {
81
+ constructor(message, command) {
82
+ super(message, "FFMPEG_ERROR", { command });
83
+ this.command = command;
84
+ this.name = "FFmpegError";
85
+ }
86
+ };
87
+ /**
88
+ * 异步操作包装器
89
+ */
90
+ async function tryCatch(operation) {
91
+ try {
92
+ return await operation();
93
+ } catch (error) {
94
+ handleError(error);
95
+ }
96
+ }
97
+ /**
98
+ * 全局错误处理器
99
+ */
100
+ function handleError(error) {
101
+ if (error instanceof AppError) {
102
+ logger.error(`[${error.code}] ${error.message}`);
103
+ if (error.details && Object.keys(error.details).length > 0) logger.dim(`详情: ${JSON.stringify(error.details, null, 2)}`);
104
+ } else if (error instanceof Error) {
105
+ logger.error(error.message);
106
+ if (process.env.DEBUG) logger.dim(error.stack || "");
107
+ } else logger.error(`未知错误: ${String(error)}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ //#endregion
112
+ //#region src/utils/file.ts
113
+ /**
114
+ * 检查文件是否存在
115
+ */
116
+ function fileExists(targetPath) {
117
+ try {
118
+ accessSync(targetPath);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+ /**
125
+ * 确保目录存在
126
+ */
127
+ function ensureDir(targetPath) {
128
+ const normalizedPath = path.normalize(targetPath);
129
+ const dirPath = path.extname(normalizedPath) ? path.dirname(normalizedPath) : normalizedPath;
130
+ if (!dirPath || dirPath === "." || dirPath === path.sep) return;
131
+ if (!fileExists(dirPath)) mkdirSync(dirPath, { recursive: true });
132
+ }
133
+ /**
134
+ * 获取文件扩展名
135
+ */
136
+ function getFileExt(filePath, options) {
137
+ const ext = path.extname(filePath).toLowerCase();
138
+ return options?.withDot === false ? ext.slice(1) : ext;
139
+ }
140
+ /**
141
+ * 获取文件名
142
+ */
143
+ function getFileName(filePath, options) {
144
+ const base = path.basename(filePath);
145
+ return options?.withoutExt ? base.replace(path.extname(base), "") : base;
146
+ }
147
+ /**
148
+ * 验证文件扩展名
149
+ */
150
+ function validateExtension(filePath, expectedExt) {
151
+ const fileName = getFileName(filePath);
152
+ const normalizedExt = expectedExt.toLowerCase().replace(/^\./, "");
153
+ return fileName.endsWith(`.${normalizedExt}`);
154
+ }
155
+ /**
156
+ * 将 Buffer 转换为 Base64 字符串
157
+ */
158
+ const bufferToBase64 = (data) => data.toString("base64");
159
+ /**
160
+ * 将 Base64 字符串转换为 Buffer
161
+ */
162
+ const base64ToBuffer = (data) => Buffer.from(data, "base64");
163
+
164
+ //#endregion
165
+ //#region src/utils/time.ts
166
+ function nowUTC8() {
167
+ const now = /* @__PURE__ */ new Date();
168
+ return new Date(now.getTime() + 480 * 60 * 1e3).toISOString().replace("T", " ").substring(0, 19);
169
+ }
170
+
171
+ //#endregion
172
+ //#region src/core/base64.ts
173
+ /**
174
+ * 将文件转换为 Base64 JSON
175
+ */
176
+ async function fileToBase64(filePath, outputPath) {
177
+ if (!filePath) throw new ValidationError("文件路径不能为空", "filePath");
178
+ if (!outputPath) throw new ValidationError("输出路径不能为空", "outputPath");
179
+ try {
180
+ ensureDir(outputPath);
181
+ const buffer = await fs.readFile(filePath);
182
+ const base64 = bufferToBase64(buffer);
183
+ const fileName = getFileName(filePath);
184
+ const fileExt = getFileExt(filePath);
185
+ const archiveData = {
186
+ type: "base64",
187
+ createdAt: nowUTC8(),
188
+ file: {
189
+ name: fileName,
190
+ extension: fileExt,
191
+ size: buffer.length,
192
+ base64
193
+ }
194
+ };
195
+ const content = JSON.stringify(archiveData, null, 2);
196
+ await fs.writeFile(outputPath, content, "utf-8");
197
+ return archiveData;
198
+ } catch (error) {
199
+ if (error instanceof ValidationError) throw error;
200
+ if (error.code === "ENOENT") throw new FileError(`文件不存在: ${filePath}`, filePath);
201
+ if (error.code === "EACCES") throw new FileError(`没有文件访问权限: ${filePath}`, filePath);
202
+ throw new ConversionError(`Base64 转换失败: ${error.message}`, "fileToBase64");
203
+ }
204
+ }
205
+ /**
206
+ * 从 Base64 JSON 恢复文件
207
+ */
208
+ async function base64ToFile(archiveData, outputDir = ".") {
209
+ if (!archiveData) throw new ValidationError("归档数据不能为空", "archiveData");
210
+ if (!archiveData.file?.base64) throw new ValidationError("归档数据格式错误", "archiveData.file.base64");
211
+ try {
212
+ ensureDir(outputDir);
213
+ const { file } = archiveData;
214
+ const buffer = base64ToBuffer(file.base64);
215
+ const outputPath = path.join(outputDir, file.name);
216
+ await fs.writeFile(outputPath, buffer);
217
+ return outputPath;
218
+ } catch (error) {
219
+ if (error instanceof ValidationError) throw error;
220
+ if (error.code === "EACCES") throw new FileError(`没有目录写入权限: ${outputDir}`, outputDir);
221
+ throw new ConversionError(`Base64 还原失败: ${error.message}`, "base64ToFile");
222
+ }
223
+ }
224
+
225
+ //#endregion
226
+ //#region src/config/defaults.ts
227
+ const CLI_NAME = "File Kit";
228
+ const CLI_VERSION = "1.0.0";
229
+ const CLI_ALIAS = "fkt";
230
+ /**
231
+ * 默认配置
232
+ */
233
+ const DEFAULT_CONFIG = {
234
+ output: {
235
+ defaultDir: "./.output",
236
+ useInputDirForBase64: true,
237
+ useInputDirForRestore: true
238
+ },
239
+ videoToAudio: {
240
+ defaultFormat: "mp3",
241
+ defaultQuality: "high"
242
+ }
243
+ };
244
+
245
+ //#endregion
246
+ //#region src/utils/prompts.ts
247
+ /**
248
+ * 处理取消操作
249
+ */
250
+ function handleCancel(result) {
251
+ if (isCancel(result)) {
252
+ logger.error(ansis.red("操作已取消"));
253
+ process.exit(0);
254
+ }
255
+ }
256
+ /**
257
+ * 文本输入
258
+ */
259
+ async function text$1(options) {
260
+ const result = await text(options);
261
+ handleCancel(result);
262
+ return result;
263
+ }
264
+ /**
265
+ * 确认输入
266
+ */
267
+ async function confirm$1(options) {
268
+ const result = await confirm(options);
269
+ handleCancel(result);
270
+ return result;
271
+ }
272
+ /**
273
+ * 选择输入
274
+ */
275
+ async function select$1(options) {
276
+ const result = await select(options);
277
+ handleCancel(result);
278
+ return result;
279
+ }
280
+ /**
281
+ * 密码输入
282
+ */
283
+ async function password$1(options) {
284
+ const result = await password(options);
285
+ handleCancel(result);
286
+ return result;
287
+ }
288
+
289
+ //#endregion
290
+ //#region src/utils/helpers.ts
291
+ /**
292
+ * 创建命令上下文
293
+ */
294
+ function createCommandContext(rawArgs) {
295
+ const isInteractive = Array.isArray(rawArgs) && rawArgs.includes("-i");
296
+ return {
297
+ isInteractive,
298
+ showIntro: () => {
299
+ if (!isInteractive) intro(bold.cyan(`🔧 ${CLI_NAME}`));
300
+ },
301
+ showOutro: (message) => {
302
+ outro(bold.green(message));
303
+ },
304
+ getInput: getInputPath,
305
+ getOutput: getOutputDir,
306
+ loading: (initialMessage) => {
307
+ const s = spinner();
308
+ s.start(initialMessage);
309
+ return {
310
+ update: (message) => s.message(message),
311
+ close: (message) => s.stop(message)
312
+ };
313
+ }
314
+ };
315
+ }
316
+ /**
317
+ * 获取并验证输入路径
318
+ */
319
+ async function getInputPath(providedPath, options) {
320
+ let inputPath = providedPath;
321
+ if (!inputPath) inputPath = await text$1({
322
+ message: options.message,
323
+ placeholder: options.placeholder,
324
+ validate: (value) => {
325
+ if (!value) return "文件路径不能为空";
326
+ if (!fileExists(value)) return "文件不存在";
327
+ if (options.validateExtension && !validateExtension(value, options.validateExtension)) return `请输入 ${options.validateExtension} 格式的文件`;
328
+ return options.customValidate?.(value);
329
+ }
330
+ });
331
+ else {
332
+ if (!fileExists(inputPath)) {
333
+ logger.error(`文件不存在: ${inputPath}`);
334
+ process.exit(1);
335
+ }
336
+ if (options.validateExtension && !validateExtension(inputPath, options.validateExtension)) {
337
+ logger.error(`请输入 ${options.validateExtension} 格式的文件`);
338
+ process.exit(1);
339
+ }
340
+ }
341
+ return inputPath;
342
+ }
343
+ async function getPassword(providedPwd) {
344
+ let userPwd = providedPwd;
345
+ if (!userPwd) {
346
+ userPwd = await password$1({
347
+ message: "请输入加密密码:",
348
+ validate: (value) => {
349
+ if (!value) return "密码不能为空";
350
+ if (value.length < 6) return "密码长度至少 6 位";
351
+ }
352
+ });
353
+ await password$1({
354
+ message: "请再次输入密码:",
355
+ validate: (value) => {
356
+ if (value !== userPwd) return "两次密码不一致";
357
+ }
358
+ });
359
+ } else if (typeof userPwd === "string" && userPwd.length < 6) {
360
+ logger.error(`'密码长度至少 6 位'`);
361
+ process.exit(1);
362
+ }
363
+ return userPwd;
364
+ }
365
+ /**
366
+ * 获取输出目录
367
+ */
368
+ async function getOutputDir(providedDir, options) {
369
+ if (providedDir) return providedDir;
370
+ if (await confirm$1({ message: `使用默认输出目录: ${cyan(options.defaultDir)}` })) return options.defaultDir;
371
+ return await text$1({
372
+ message: options.promptMessage || "请输入输出目录",
373
+ placeholder: options.placeholder || "./.output"
374
+ });
375
+ }
376
+ /**
377
+ * 构建输出路径
378
+ */
379
+ function buildOutputPath(inputPath, outputDir, newExt) {
380
+ const baseName = getFileName(inputPath, { withoutExt: !!newExt });
381
+ const fileName = newExt ? `${baseName}.${newExt}` : baseName;
382
+ return path.join(outputDir, fileName);
383
+ }
384
+ async function loadArchive(filePath, type) {
385
+ if (!filePath) throw new ValidationError("文件路径不能为空", "filePath");
386
+ try {
387
+ const content = await fs.readFile(filePath, "utf-8");
388
+ const data = JSON.parse(content);
389
+ if (!data.type || data.type !== "base64" && data.type !== "crypto") throw new ValidationError("不是有效的归档文件(type 必须是 base64 或 crypto)", "archiveData.type");
390
+ if (data.type !== type) throw new ValidationError(`期望的归档类型是 ${type},但实际是 ${data.type}`, "archiveData.type");
391
+ if (type === "base64") {
392
+ const base64Data = data;
393
+ if (!base64Data.file?.base64) throw new ValidationError("Base64 归档文件缺少 base64 数据", "archiveData.file.base64");
394
+ return base64Data;
395
+ } else {
396
+ const cryptoData = data;
397
+ for (const field of [
398
+ "iv",
399
+ "authTag",
400
+ "salt",
401
+ "encrypted"
402
+ ]) if (!cryptoData.file?.[field]) throw new ValidationError(`加密归档文件缺少 ${field} 数据`, `archiveData.file.${field}`);
403
+ return cryptoData;
404
+ }
405
+ } catch (error) {
406
+ if (error instanceof ValidationError) throw error;
407
+ if (error.code === "ENOENT") throw new FileError(`归档文件不存在: ${filePath}`, filePath);
408
+ if (error instanceof SyntaxError) throw new ValidationError("归档文件 JSON 格式错误", "json");
409
+ throw new AppError(`加载归档失败: ${error.message}`, "loadArchive");
410
+ }
411
+ }
412
+
413
+ //#endregion
414
+ //#region src/commands/base64.ts
415
+ var base64_default = defineCommand({
416
+ meta: {
417
+ name: "base64",
418
+ description: "将文件转换为 Base64 JSON"
419
+ },
420
+ args: {
421
+ input: {
422
+ type: "positional",
423
+ description: "输入文件路径"
424
+ },
425
+ output: {
426
+ type: "string",
427
+ alias: "o",
428
+ description: "输出目录"
429
+ }
430
+ },
431
+ async run({ args, rawArgs }) {
432
+ const typedArgs = args;
433
+ const ctx = createCommandContext(rawArgs);
434
+ ctx.showIntro();
435
+ tryCatch(async () => {
436
+ const inputPath = await ctx.getInput(typedArgs.input, {
437
+ message: "请输入文件路径",
438
+ placeholder: "file.txt"
439
+ });
440
+ const outputPath = buildOutputPath(inputPath, await ctx.getOutput(typedArgs.output, { defaultDir: path.dirname(inputPath) }), "base64.json");
441
+ const loading = ctx.loading("正在转换");
442
+ const archiveData = await fileToBase64(inputPath, outputPath);
443
+ loading.close(`文件已保存到: ${cyan(outputPath)}, 共计 ${bold.gray((archiveData.file.size / 1024).toFixed(2))} KB`);
444
+ ctx.showOutro("📦 转换完成");
445
+ });
446
+ }
447
+ });
448
+
449
+ //#endregion
450
+ //#region src/config/crypto-algorithm.ts
451
+ const CRYPTO_ALGORITHM = {
452
+ name: "aes-256-gcm",
453
+ ivLength: 16,
454
+ saltLength: 32,
455
+ iterations: 1e5
456
+ };
457
+
458
+ //#endregion
459
+ //#region src/core/crypto.ts
460
+ /**
461
+ * 从密码派生密钥
462
+ */
463
+ const deriveKey = (password$2, salt) => {
464
+ return pbkdf2Sync(password$2, salt, 1e5, 32, "sha256");
465
+ };
466
+ /**
467
+ * 加密文件
468
+ */
469
+ async function encrypt(filePath, outputPath, options) {
470
+ if (!filePath) throw new ValidationError("文件路径不能为空", "filePath");
471
+ if (!outputPath) throw new ValidationError("输出路径不能为空", "outputPath");
472
+ try {
473
+ ensureDir(outputPath);
474
+ const fileBuffer = await fs.readFile(filePath);
475
+ const fileName = getFileName(filePath);
476
+ const fileExt = getFileExt(filePath);
477
+ const salt = randomBytes(CRYPTO_ALGORITHM.saltLength);
478
+ const iv = randomBytes(CRYPTO_ALGORITHM.ivLength);
479
+ const key = deriveKey(options.password, salt);
480
+ const cipher = createCipheriv(CRYPTO_ALGORITHM.name, key, iv);
481
+ const encrypted = Buffer.concat([cipher.update(fileBuffer), cipher.final()]);
482
+ const authTag = cipher.getAuthTag();
483
+ const encryptedData = {
484
+ type: "crypto",
485
+ algorithm: CRYPTO_ALGORITHM.name,
486
+ createdAt: nowUTC8(),
487
+ file: {
488
+ name: fileName,
489
+ extension: fileExt,
490
+ size: fileBuffer.length,
491
+ iv: bufferToBase64(iv),
492
+ authTag: bufferToBase64(authTag),
493
+ salt: bufferToBase64(salt),
494
+ encrypted: bufferToBase64(encrypted)
495
+ }
496
+ };
497
+ const content = JSON.stringify(encryptedData, null, 2);
498
+ await fs.writeFile(outputPath, content, "utf-8");
499
+ return encryptedData;
500
+ } catch (error) {
501
+ if (error instanceof ValidationError) throw error;
502
+ if (error.code === "ENOENT") throw new FileError(`文件不存在: ${filePath}`, filePath);
503
+ if (error.code === "EACCES") throw new FileError(`没有文件访问权限: ${filePath}`, filePath);
504
+ throw new CryptoError(`加密失败: ${error.message}`, "encrypt");
505
+ }
506
+ }
507
+ /**
508
+ * 解密文件
509
+ */
510
+ async function decrypt(archiveData, outputDir = ".", options) {
511
+ if (!archiveData) throw new ValidationError("归档数据不能为空", "archiveData");
512
+ if (!archiveData.file?.encrypted) throw new ValidationError("归档数据格式错误", "archiveData.file.encrypted");
513
+ try {
514
+ ensureDir(outputDir);
515
+ const { file } = archiveData;
516
+ const salt = base64ToBuffer(file.salt);
517
+ const iv = base64ToBuffer(file.iv);
518
+ const authTag = base64ToBuffer(file.authTag);
519
+ const encrypted = base64ToBuffer(file.encrypted);
520
+ const key = deriveKey(options.password, salt);
521
+ const decipher = createDecipheriv(CRYPTO_ALGORITHM.name, key, iv);
522
+ decipher.setAuthTag(authTag);
523
+ let decrypted;
524
+ try {
525
+ decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
526
+ } catch {
527
+ throw new Error("解密失败:密码错误或文件已损坏");
528
+ }
529
+ const outputPath = path.join(outputDir, file.name);
530
+ await fs.writeFile(outputPath, decrypted);
531
+ return outputPath;
532
+ } catch (error) {
533
+ if (error instanceof ValidationError) throw error;
534
+ if (error.code === "EACCES") throw new FileError(`没有目录写入权限: ${outputDir}`, outputDir);
535
+ throw new CryptoError(`解密失败: ${error.message}`, "decrypt");
536
+ }
537
+ }
538
+
539
+ //#endregion
540
+ //#region src/commands/decrypt.ts
541
+ var decrypt_default = defineCommand({
542
+ meta: {
543
+ name: "decrypt",
544
+ description: "解密文件"
545
+ },
546
+ args: {
547
+ input: {
548
+ type: "positional",
549
+ description: "加密文件 (*.crypto.json)",
550
+ required: true
551
+ },
552
+ output: {
553
+ type: "string",
554
+ alias: "o",
555
+ description: "输出目录"
556
+ },
557
+ password: {
558
+ type: "string",
559
+ alias: "p",
560
+ description: "解密密码"
561
+ }
562
+ },
563
+ async run({ args, rawArgs }) {
564
+ const typedArgs = args;
565
+ const ctx = createCommandContext(rawArgs);
566
+ ctx.showIntro();
567
+ tryCatch(async () => {
568
+ const inputPath = await ctx.getInput(typedArgs.input, {
569
+ message: "请输入文件路径",
570
+ placeholder: "*.crypto.json",
571
+ validateExtension: ".crypto.json"
572
+ });
573
+ const outputDir = await ctx.getOutput(typedArgs.output, { defaultDir: path.dirname(inputPath) });
574
+ const password$2 = await getPassword(typedArgs.password);
575
+ const loading = ctx.loading("正在解密");
576
+ const outputPath = await decrypt(await loadArchive(inputPath, "crypto"), outputDir, { password: password$2 });
577
+ loading.close(`文件已解密到: ${cyan(outputPath)}`);
578
+ ctx.showOutro("🔓 解密完成");
579
+ });
580
+ }
581
+ });
582
+
583
+ //#endregion
584
+ //#region src/commands/encrypt.ts
585
+ var encrypt_default = defineCommand({
586
+ meta: {
587
+ name: "encrypt",
588
+ description: "加密文件"
589
+ },
590
+ args: {
591
+ input: {
592
+ type: "positional",
593
+ description: "文件路径",
594
+ required: true
595
+ },
596
+ output: {
597
+ type: "string",
598
+ alias: "o",
599
+ description: "输出目录"
600
+ },
601
+ password: {
602
+ type: "string",
603
+ alias: "p",
604
+ description: "加密密钥"
605
+ }
606
+ },
607
+ async run({ args, rawArgs }) {
608
+ const typedArgs = args;
609
+ const ctx = createCommandContext(rawArgs);
610
+ ctx.showIntro();
611
+ tryCatch(async () => {
612
+ const inputPath = await ctx.getInput(typedArgs.input, {
613
+ message: "请输入文件路径",
614
+ placeholder: "file.txt"
615
+ });
616
+ const outputDir = await ctx.getOutput(typedArgs.output, { defaultDir: path$1.dirname(inputPath) });
617
+ const password$2 = await getPassword(typedArgs.password);
618
+ console.log(gray("│"));
619
+ logger.warn(yellow("请妥善保管密码,丢失后无法恢复文件!"));
620
+ const outputPath = buildOutputPath(inputPath, outputDir, "crypto.json");
621
+ const loading = ctx.loading("正在加密");
622
+ const archiveData = await encrypt(inputPath, outputPath, { password: password$2 });
623
+ loading.close(`文件已加密到: ${cyan(outputPath)}, 共计 ${bold.gray((archiveData.file.size / 1024).toFixed(2))} KB`);
624
+ ctx.showOutro("🔐 加密完成");
625
+ });
626
+ }
627
+ });
628
+
629
+ //#endregion
630
+ //#region src/commands/restore.ts
631
+ var restore_default = defineCommand({
632
+ meta: {
633
+ name: "restore",
634
+ description: "从 Base64 JSON 恢复文件"
635
+ },
636
+ args: {
637
+ input: {
638
+ type: "positional",
639
+ description: "Base64 文件 (*.base64.json)"
640
+ },
641
+ output: {
642
+ type: "string",
643
+ alias: "o",
644
+ description: "输出目录"
645
+ }
646
+ },
647
+ async run({ args, rawArgs }) {
648
+ const typedArgs = args;
649
+ const ctx = createCommandContext(rawArgs);
650
+ ctx.showIntro();
651
+ tryCatch(async () => {
652
+ const inputPath = await ctx.getInput(typedArgs.input, {
653
+ message: "请输入文件路径",
654
+ placeholder: "*.base64.json",
655
+ validateExtension: "base64.json"
656
+ });
657
+ const outputDir = await ctx.getOutput(typedArgs.output, { defaultDir: path.dirname(inputPath) });
658
+ const loading = ctx.loading("正在恢复");
659
+ const archiveData = await loadArchive(inputPath, "base64");
660
+ const restoredPath = await base64ToFile(archiveData, outputDir);
661
+ loading.close(`文件已恢复到: ${cyan(restoredPath)}, 原始创建时间 ${bold.gray(archiveData.createdAt)}`);
662
+ ctx.showOutro("🔄 恢复完成");
663
+ });
664
+ }
665
+ });
666
+
667
+ //#endregion
668
+ //#region src/config/audio-formats.ts
669
+ /**
670
+ * 音频格式配置表
671
+ */
672
+ const AUDIO_FORMATS = {
673
+ mp3: {
674
+ codec: "libmp3lame",
675
+ extension: "mp3",
676
+ quality: {
677
+ low: "128k",
678
+ medium: "192k",
679
+ high: "320k"
680
+ },
681
+ description: "MP3 (通用)",
682
+ needsQuality: true,
683
+ extraArgs: []
684
+ },
685
+ aac: {
686
+ codec: "aac",
687
+ extension: "m4a",
688
+ quality: {
689
+ low: "128k",
690
+ medium: "192k",
691
+ high: "256k"
692
+ },
693
+ description: "AAC/M4A (Apple)",
694
+ needsQuality: true,
695
+ extraArgs: ["-movflags", "+faststart"]
696
+ },
697
+ flac: {
698
+ codec: "flac",
699
+ extension: "flac",
700
+ quality: {
701
+ low: "0",
702
+ medium: "5",
703
+ high: "8"
704
+ },
705
+ description: "FLAC (无损)",
706
+ needsQuality: true,
707
+ extraArgs: ["-sample_fmt", "s16"]
708
+ },
709
+ alac: {
710
+ codec: "alac",
711
+ extension: "m4a",
712
+ quality: {
713
+ low: "0",
714
+ medium: "0",
715
+ high: "0"
716
+ },
717
+ description: "ALAC (Apple 无损)",
718
+ needsQuality: false,
719
+ extraArgs: ["-sample_fmt", "s16p"]
720
+ },
721
+ wav: {
722
+ codec: "pcm_s16le",
723
+ extension: "wav",
724
+ quality: {
725
+ low: "0",
726
+ medium: "0",
727
+ high: "0"
728
+ },
729
+ description: "WAV (无损)",
730
+ needsQuality: false,
731
+ extraArgs: []
732
+ }
733
+ };
734
+
735
+ //#endregion
736
+ //#region src/core/extract.ts
737
+ /**
738
+ * 解析时间字符串为秒
739
+ */
740
+ const parseTime = (hours, minutes, seconds) => Number.parseInt(hours) * 3600 + Number.parseInt(minutes) * 60 + Number.parseFloat(seconds);
741
+ /**
742
+ * 构建 FFmpeg 参数
743
+ */
744
+ const buildFFmpegArgs = (filePath, outputPath, options) => {
745
+ const formatConfig = AUDIO_FORMATS[options.format];
746
+ const quality = options.quality || DEFAULT_CONFIG.videoToAudio.defaultQuality;
747
+ const bitrateOrQuality = options.bitrate || formatConfig.quality[quality];
748
+ const args = [
749
+ "-i",
750
+ filePath,
751
+ "-vn"
752
+ ];
753
+ args.push("-ac", "2");
754
+ args.push("-acodec", formatConfig.codec);
755
+ if (options.format === "flac") args.push("-compression_level", bitrateOrQuality);
756
+ else if (formatConfig.needsQuality) args.push("-b:a", bitrateOrQuality);
757
+ if (formatConfig.extraArgs && formatConfig.extraArgs.length > 0) args.push(...formatConfig.extraArgs);
758
+ args.push("-y", outputPath);
759
+ return args;
760
+ };
761
+ /**
762
+ * 从视频中提取音频
763
+ */
764
+ async function extractAudio(filePath, outputPath, options, onProgress) {
765
+ if (!filePath) throw new ValidationError("视频文件路径不能为空", "filePath");
766
+ if (!outputPath) throw new ValidationError("输出路径不能为空", "outputPath");
767
+ if (!ffmpegPath.path || !existsSync(ffmpegPath.path)) throw new FFmpegError(`FFmpeg 未找到,路径: ${ffmpegPath.path || "undefined"}`, ffmpegPath.path);
768
+ if (!AUDIO_FORMATS[options.format]) throw new ValidationError(`不支持的音频格式: ${options.format}`, "format");
769
+ try {
770
+ ensureDir(outputPath);
771
+ const args = buildFFmpegArgs(filePath, outputPath, options);
772
+ const subprocess = execa(ffmpegPath.path, args);
773
+ if (onProgress && subprocess.stderr) {
774
+ let duration = 0;
775
+ subprocess.stderr.on("data", (data) => {
776
+ const text$2 = data.toString();
777
+ const durationMatch = text$2.match(/Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})/);
778
+ if (durationMatch) duration = parseTime(durationMatch[1], durationMatch[2], durationMatch[3]);
779
+ const timeMatch = text$2.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
780
+ if (timeMatch && duration > 0) {
781
+ const currentTime = parseTime(timeMatch[1], timeMatch[2], timeMatch[3]);
782
+ onProgress(Math.min(Math.round(currentTime / duration * 100), 100));
783
+ }
784
+ });
785
+ }
786
+ await subprocess;
787
+ return outputPath;
788
+ } catch (error) {
789
+ if (error instanceof ValidationError) throw error;
790
+ if (error.message.includes("ENOENT")) throw new FFmpegError("FFmpeg 未找到,请确保已正确安装");
791
+ if (error.message.includes("Invalid")) throw new FFmpegError(`无效的参数: ${error.message}`);
792
+ const args = buildFFmpegArgs(filePath, "", options);
793
+ throw new FFmpegError(`FFmpeg 转换失败: ${error.message}`, args.join(" "));
794
+ }
795
+ }
796
+
797
+ //#endregion
798
+ //#region src/commands/video-to-audio.ts
799
+ const FORMAT_OPTIONS = [
800
+ {
801
+ value: "mp3",
802
+ label: "MP3 (通用)",
803
+ hint: "128k-320k"
804
+ },
805
+ {
806
+ value: "aac",
807
+ label: "AAC/M4A (Apple)",
808
+ hint: "128k-256k"
809
+ },
810
+ {
811
+ value: "flac",
812
+ label: "FLAC (无损)",
813
+ hint: "可压缩"
814
+ },
815
+ {
816
+ value: "alac",
817
+ label: "ALAC (Apple 无损)",
818
+ hint: "iTunes/iOS"
819
+ },
820
+ {
821
+ value: "wav",
822
+ label: "WAV (无损)",
823
+ hint: "最大"
824
+ }
825
+ ];
826
+ const getQualityOptions = (format) => {
827
+ if (format === "flac") return [
828
+ {
829
+ value: "low",
830
+ label: "快速",
831
+ hint: "级别 0"
832
+ },
833
+ {
834
+ value: "medium",
835
+ label: "平衡 (推荐)",
836
+ hint: "级别 5"
837
+ },
838
+ {
839
+ value: "high",
840
+ label: "最大压缩",
841
+ hint: "级别 8"
842
+ }
843
+ ];
844
+ const formatConfig = AUDIO_FORMATS[format];
845
+ return [
846
+ {
847
+ value: "low",
848
+ label: "低",
849
+ hint: formatConfig.quality.low
850
+ },
851
+ {
852
+ value: "medium",
853
+ label: "中 (推荐)",
854
+ hint: formatConfig.quality.medium
855
+ },
856
+ {
857
+ value: "high",
858
+ label: "高",
859
+ hint: formatConfig.quality.high
860
+ }
861
+ ];
862
+ };
863
+ var video_to_audio_default = defineCommand({
864
+ meta: {
865
+ name: "video-to-audio",
866
+ description: "从视频中提取音频"
867
+ },
868
+ args: {
869
+ input: {
870
+ type: "positional",
871
+ description: "视频文件路径"
872
+ },
873
+ output: {
874
+ type: "string",
875
+ alias: "o",
876
+ description: "输出目录"
877
+ },
878
+ format: {
879
+ type: "string",
880
+ alias: "f",
881
+ description: "音频格式 (mp3, aac, flac, alac, wav)"
882
+ },
883
+ quality: {
884
+ type: "string",
885
+ alias: "q",
886
+ description: "音频质量 (low, medium, high)"
887
+ }
888
+ },
889
+ async run({ args, rawArgs }) {
890
+ const typedArgs = args;
891
+ const ctx = createCommandContext(rawArgs);
892
+ ctx.showIntro();
893
+ tryCatch(async () => {
894
+ const inputPath = await ctx.getInput(typedArgs.input, {
895
+ message: "请输入视频文件路径",
896
+ placeholder: "video.mp4"
897
+ });
898
+ const outputDir = await ctx.getOutput(typedArgs.output, { defaultDir: path.dirname(inputPath) });
899
+ let format = typedArgs.format;
900
+ if (!format) format = await select$1({
901
+ message: "选择音频格式",
902
+ options: FORMAT_OPTIONS
903
+ });
904
+ else if (!AUDIO_FORMATS[format]) {
905
+ logger.error(`不支持的格式: ${format}`);
906
+ logger.info(`支持的格式: ${Object.keys(AUDIO_FORMATS).join(", ")}`);
907
+ process.exit(1);
908
+ }
909
+ const formatConfig = AUDIO_FORMATS[format];
910
+ let quality;
911
+ if (!typedArgs.quality && formatConfig.needsQuality) quality = await select$1({
912
+ message: format === "flac" ? "选择压缩级别" : "选择音频质量",
913
+ options: getQualityOptions(format)
914
+ });
915
+ else if (typedArgs.quality) quality = typedArgs.quality;
916
+ else quality = DEFAULT_CONFIG.videoToAudio.defaultQuality;
917
+ const outputPath = buildOutputPath(inputPath, outputDir, formatConfig.extension);
918
+ const loading = ctx.loading("正在提取音频 0%");
919
+ await extractAudio(inputPath, outputPath, {
920
+ format,
921
+ quality
922
+ }, (percent) => {
923
+ loading.update(`正在提取音频 ${percent}%`);
924
+ });
925
+ loading.close(`音频已提取到: ${cyan(outputPath)}`);
926
+ ctx.showOutro("🎵 提取完成");
927
+ });
928
+ }
929
+ });
930
+
931
+ //#endregion
932
+ //#region src/cli.ts
933
+ const COMMAND_MAP = {
934
+ base64: base64_default,
935
+ restore: restore_default,
936
+ "video-to-audio": video_to_audio_default,
937
+ encrypt: encrypt_default,
938
+ decrypt: decrypt_default
939
+ };
940
+ const INTERACTIVE_OPTIONS = [
941
+ {
942
+ value: "base64",
943
+ label: ansis.cyan("📦 文件转 Base64"),
944
+ hint: "将任意文件编码为 Base64 JSON"
945
+ },
946
+ {
947
+ value: "restore",
948
+ label: ansis.green("🔄 Base64 还原文件"),
949
+ hint: "从 Base64 JSON 恢复原始文件"
950
+ },
951
+ {
952
+ value: "video-to-audio",
953
+ label: ansis.magenta("🎵 视频提取音频"),
954
+ hint: "从视频中提取音频轨道"
955
+ },
956
+ {
957
+ value: "encrypt",
958
+ label: ansis.red("🔐 文件加密"),
959
+ hint: "加密文件并生成 Crypto JSON"
960
+ },
961
+ {
962
+ value: "decrypt",
963
+ label: ansis.green("🔓 文件解密"),
964
+ hint: "从 Crypto JSON 解密还原文件"
965
+ }
966
+ ];
967
+ const cliArgs = {
968
+ help: false,
969
+ version: false
970
+ };
971
+ function preprocessArgs(rawArgs) {
972
+ cliArgs.help = rawArgs.some((arg) => arg === "--help" || arg === "-h");
973
+ cliArgs.version = rawArgs.some((arg) => arg === "--version" || arg === "-v");
974
+ if (cliArgs.help) process.argv = rawArgs.filter((arg) => arg !== "--help" && arg !== "-h");
975
+ }
976
+ /**
977
+ * 显示版本信息
978
+ */
979
+ function showVersion() {
980
+ console.log(ansis.cyan(`
981
+ ╭──────────────────────────╮
982
+ │ 🔧 ${ansis.bold(CLI_NAME)} · ${ansis.dim(`v${CLI_VERSION}`.padEnd(9))}│
983
+ ╰──────────────────────────╯
984
+ `));
985
+ console.log(ansis.bold(" 多功能文件工具箱\n"));
986
+ console.log(ansis.gray(" 🔄 Base64 互转 🎧 音频提取 🔐 文件加密\n"));
987
+ }
988
+ /**
989
+ * 显示帮助信息
990
+ */
991
+ function showHelp() {
992
+ console.log(`${ansis.bold(`🔧 ${CLI_NAME}`)}${ansis.dim(` - 多功能文件工具箱 (${CLI_ALIAS} v${CLI_VERSION})`)}\n`);
993
+ console.log(ansis.bold("用法:"));
994
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.dim("<command> [options]")} 执行指定命令`);
995
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("-i, --interactive")} 进入交互模式`);
996
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("-v, --version")} 显示版本信息`);
997
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("-h, --help")} 显示帮助信息${ansis.dim("(默认)")}\n`);
998
+ console.log(ansis.bold("命令:"));
999
+ console.log(` ${ansis.cyan("base64")}${ansis.dim(" 文件转 Base64")}`);
1000
+ console.log(` ${ansis.green("restore")}${ansis.dim(" Base64 还原文件")}`);
1001
+ console.log(` ${ansis.magenta("video-to-audio, v2a")}${ansis.dim(" 视频提取音频")}`);
1002
+ console.log(` ${ansis.red("encrypt")}${ansis.dim(" 加密文件")}`);
1003
+ console.log(` ${ansis.green("decrypt")}${ansis.dim(" 解密文件")}\n`);
1004
+ console.log(ansis.bold("示例:"));
1005
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("base64")} ${ansis.green("file.txt")} ${ansis.dim("转换文件为 Base64")}`);
1006
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("restore")} ${ansis.green("file.json")} ${ansis.dim("还原 Base64 文件")}`);
1007
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("v2a")} ${ansis.green("video.mp4")} ${ansis.blue("-f mp3")} ${ansis.dim("提取视频音频为 MP3")}`);
1008
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("encrypt")} ${ansis.green("secret.txt")} ${ansis.blue("-p pwd")} ${ansis.dim("加密文件")}`);
1009
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("decrypt")} ${ansis.green("secret.json")} ${ansis.blue("-p pwd")} ${ansis.dim("解密文件")}`);
1010
+ console.log(` ${ansis.yellow(CLI_ALIAS)} ${ansis.cyan("-i")} ${ansis.dim("交互式选择功能")}\n`);
1011
+ }
1012
+ /**
1013
+ * 交互模式
1014
+ */
1015
+ async function runInteractiveMode() {
1016
+ intro(ansis.bold.cyan(`🔧 ${CLI_NAME}`));
1017
+ const choice = await select$1({
1018
+ message: "选择功能",
1019
+ options: INTERACTIVE_OPTIONS
1020
+ });
1021
+ const selectedCommand = COMMAND_MAP[choice];
1022
+ if (!selectedCommand) {
1023
+ logger.error(`未知命令: ${choice}`);
1024
+ process.exit(1);
1025
+ }
1026
+ await selectedCommand.run?.({
1027
+ rawArgs: ["-i"],
1028
+ args: { _: [] },
1029
+ cmd: {}
1030
+ });
1031
+ }
1032
+ const main = defineCommand({
1033
+ meta: {
1034
+ name: "fkt",
1035
+ version: "2.0.0",
1036
+ description: `🔧 ${CLI_NAME} - 多功能文件工具箱`
1037
+ },
1038
+ args: { interactive: {
1039
+ type: "boolean",
1040
+ alias: "i",
1041
+ description: "进入交互模式",
1042
+ default: false
1043
+ } },
1044
+ subCommands: {
1045
+ base64: () => base64_default,
1046
+ restore: () => restore_default,
1047
+ "video-to-audio": () => video_to_audio_default,
1048
+ v2a: () => video_to_audio_default,
1049
+ encrypt: encrypt_default,
1050
+ decrypt: decrypt_default
1051
+ },
1052
+ async run({ args, rawArgs }) {
1053
+ if (args.interactive) await runInteractiveMode();
1054
+ if (cliArgs.version) {
1055
+ showVersion();
1056
+ process.exit(0);
1057
+ }
1058
+ if (cliArgs.help || rawArgs.length === 0) {
1059
+ showHelp();
1060
+ process.exit(0);
1061
+ }
1062
+ }
1063
+ });
1064
+ preprocessArgs(process.argv);
1065
+ runMain(main);
1066
+
1067
+ //#endregion