noupload 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.
Files changed (74) hide show
  1. package/.github/workflows/release.yml +73 -0
  2. package/LICENSE +21 -0
  3. package/README.md +118 -0
  4. package/biome.json +34 -0
  5. package/bunfig.toml +7 -0
  6. package/dist/index.js +192 -0
  7. package/install.sh +68 -0
  8. package/package.json +47 -0
  9. package/scripts/inspect-help.ts +15 -0
  10. package/site/index.html +112 -0
  11. package/src/cli.ts +24 -0
  12. package/src/commands/audio/convert.ts +107 -0
  13. package/src/commands/audio/extract.ts +84 -0
  14. package/src/commands/audio/fade.ts +128 -0
  15. package/src/commands/audio/index.ts +35 -0
  16. package/src/commands/audio/merge.ts +109 -0
  17. package/src/commands/audio/normalize.ts +110 -0
  18. package/src/commands/audio/reverse.ts +64 -0
  19. package/src/commands/audio/speed.ts +101 -0
  20. package/src/commands/audio/trim.ts +98 -0
  21. package/src/commands/audio/volume.ts +91 -0
  22. package/src/commands/audio/waveform.ts +117 -0
  23. package/src/commands/doctor.ts +125 -0
  24. package/src/commands/image/adjust.ts +129 -0
  25. package/src/commands/image/border.ts +94 -0
  26. package/src/commands/image/bulk-compress.ts +111 -0
  27. package/src/commands/image/bulk-convert.ts +114 -0
  28. package/src/commands/image/bulk-resize.ts +112 -0
  29. package/src/commands/image/compress.ts +95 -0
  30. package/src/commands/image/convert.ts +116 -0
  31. package/src/commands/image/crop.ts +96 -0
  32. package/src/commands/image/favicon.ts +89 -0
  33. package/src/commands/image/filters.ts +108 -0
  34. package/src/commands/image/index.ts +49 -0
  35. package/src/commands/image/resize.ts +110 -0
  36. package/src/commands/image/rotate.ts +90 -0
  37. package/src/commands/image/strip-metadata.ts +60 -0
  38. package/src/commands/image/to-base64.ts +72 -0
  39. package/src/commands/image/watermark.ts +141 -0
  40. package/src/commands/pdf/compress.ts +157 -0
  41. package/src/commands/pdf/decrypt.ts +102 -0
  42. package/src/commands/pdf/delete-pages.ts +112 -0
  43. package/src/commands/pdf/duplicate.ts +119 -0
  44. package/src/commands/pdf/encrypt.ts +161 -0
  45. package/src/commands/pdf/from-images.ts +104 -0
  46. package/src/commands/pdf/index.ts +55 -0
  47. package/src/commands/pdf/merge.ts +84 -0
  48. package/src/commands/pdf/ocr.ts +270 -0
  49. package/src/commands/pdf/organize.ts +88 -0
  50. package/src/commands/pdf/page-numbers.ts +152 -0
  51. package/src/commands/pdf/reverse.ts +71 -0
  52. package/src/commands/pdf/rotate.ts +116 -0
  53. package/src/commands/pdf/sanitize.ts +77 -0
  54. package/src/commands/pdf/sign.ts +156 -0
  55. package/src/commands/pdf/split.ts +148 -0
  56. package/src/commands/pdf/to-images.ts +84 -0
  57. package/src/commands/pdf/to-text.ts +51 -0
  58. package/src/commands/pdf/watermark.ts +179 -0
  59. package/src/commands/qr/bulk-generate.ts +136 -0
  60. package/src/commands/qr/generate.ts +128 -0
  61. package/src/commands/qr/index.ts +16 -0
  62. package/src/commands/qr/scan.ts +114 -0
  63. package/src/commands/setup.ts +156 -0
  64. package/src/index.ts +42 -0
  65. package/src/lib/audio/ffmpeg.ts +93 -0
  66. package/src/utils/colors.ts +41 -0
  67. package/src/utils/detect.ts +222 -0
  68. package/src/utils/errors.ts +89 -0
  69. package/src/utils/files.ts +148 -0
  70. package/src/utils/logger.ts +90 -0
  71. package/src/utils/pdf-tools.ts +220 -0
  72. package/src/utils/progress.ts +142 -0
  73. package/src/utils/style.ts +38 -0
  74. package/tsconfig.json +27 -0
@@ -0,0 +1,95 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
4
+ import {
5
+ ensureDir,
6
+ generateOutputPath,
7
+ getExtension,
8
+ getFileSize,
9
+ resolvePath,
10
+ } from '../../utils/files';
11
+ import { fileResult, success } from '../../utils/logger';
12
+ import { withSpinner } from '../../utils/progress';
13
+
14
+ export const compressCmd = defineCommand({
15
+ meta: {
16
+ name: 'compress',
17
+ description: 'Compress an image to reduce file size',
18
+ },
19
+ args: {
20
+ input: {
21
+ type: 'positional',
22
+ description: 'Input image file',
23
+ required: true,
24
+ },
25
+ output: {
26
+ type: 'string',
27
+ alias: 'o',
28
+ description: 'Output file path',
29
+ },
30
+ quality: {
31
+ type: 'string',
32
+ alias: 'q',
33
+ description: 'Quality (1-100)',
34
+ default: '80',
35
+ },
36
+ },
37
+ async run({ args }) {
38
+ try {
39
+ const input = requireArg(args.input, 'input');
40
+
41
+ const inputPath = resolvePath(input as string);
42
+ if (!existsSync(inputPath)) {
43
+ throw new FileNotFoundError(input as string);
44
+ }
45
+
46
+ const outputPath = generateOutputPath(inputPath, args.output, '-compressed');
47
+ ensureDir(outputPath);
48
+
49
+ const inputSize = getFileSize(inputPath);
50
+ const quality = Number.parseInt(args.quality || '80', 10);
51
+ const ext = getExtension(inputPath);
52
+
53
+ // Lazy load sharp
54
+ const { default: sharp } = await import('sharp');
55
+
56
+ await withSpinner(
57
+ `Compressing image (quality: ${quality})...`,
58
+ async () => {
59
+ let pipeline = sharp(inputPath);
60
+
61
+ switch (ext) {
62
+ case 'jpg':
63
+ case 'jpeg':
64
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
65
+ break;
66
+ case 'png':
67
+ pipeline = pipeline.png({ quality, compressionLevel: 9 });
68
+ break;
69
+ case 'webp':
70
+ pipeline = pipeline.webp({ quality });
71
+ break;
72
+ case 'avif':
73
+ pipeline = pipeline.avif({ quality });
74
+ break;
75
+ default:
76
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
77
+ }
78
+
79
+ await pipeline.toFile(outputPath);
80
+ },
81
+ 'Image compressed successfully'
82
+ );
83
+
84
+ const outputSize = getFileSize(outputPath);
85
+ fileResult(inputPath, outputPath, {
86
+ before: inputSize,
87
+ after: outputSize,
88
+ });
89
+
90
+ success('Image compressed');
91
+ } catch (err) {
92
+ handleError(err);
93
+ }
94
+ },
95
+ });
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
4
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
+ import {
6
+ ensureDir,
7
+ generateOutputPath,
8
+ getExtension,
9
+ getFileSize,
10
+ resolvePath,
11
+ } from '../../utils/files';
12
+ import { fileResult, info, success } from '../../utils/logger';
13
+ import { withSpinner } from '../../utils/progress';
14
+
15
+ const SUPPORTED_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'tiff', 'heif'];
16
+
17
+ export const convert = defineCommand({
18
+ meta: {
19
+ name: 'convert',
20
+ description: 'Convert an image to a different format',
21
+ },
22
+ args: {
23
+ input: {
24
+ type: 'positional',
25
+ description: 'Input image file',
26
+ required: true,
27
+ },
28
+ output: {
29
+ type: 'string',
30
+ alias: 'o',
31
+ description: 'Output file path',
32
+ },
33
+ format: {
34
+ type: 'string',
35
+ alias: 'f',
36
+ description: `Output format: ${SUPPORTED_FORMATS.join(', ')}`,
37
+ required: true,
38
+ },
39
+ quality: {
40
+ type: 'string',
41
+ alias: 'q',
42
+ description: 'Quality (1-100)',
43
+ default: '85',
44
+ },
45
+ },
46
+ async run({ args }) {
47
+ try {
48
+ const input = requireArg(args.input, 'input');
49
+ const format = requireArg(args.format, 'format').toLowerCase();
50
+
51
+ if (!SUPPORTED_FORMATS.includes(format)) {
52
+ throw new Error(
53
+ `Unsupported format: ${format}. Supported: ${SUPPORTED_FORMATS.join(', ')}`
54
+ );
55
+ }
56
+
57
+ const inputPath = resolvePath(input as string);
58
+ if (!existsSync(inputPath)) {
59
+ throw new FileNotFoundError(input as string);
60
+ }
61
+
62
+ const inputExt = getExtension(inputPath);
63
+ const outputPath = generateOutputPath(inputPath, args.output, '', format);
64
+ ensureDir(outputPath);
65
+
66
+ const inputSize = getFileSize(inputPath);
67
+ const quality = Number.parseInt(args.quality || '85', 10);
68
+
69
+ await withSpinner(
70
+ `Converting ${inputExt.toUpperCase()} to ${format.toUpperCase()}...`,
71
+ async () => {
72
+ let pipeline = sharp(inputPath);
73
+
74
+ switch (format) {
75
+ case 'jpg':
76
+ case 'jpeg':
77
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
78
+ break;
79
+ case 'png':
80
+ pipeline = pipeline.png({ quality });
81
+ break;
82
+ case 'webp':
83
+ pipeline = pipeline.webp({ quality });
84
+ break;
85
+ case 'avif':
86
+ pipeline = pipeline.avif({ quality });
87
+ break;
88
+ case 'gif':
89
+ pipeline = pipeline.gif();
90
+ break;
91
+ case 'tiff':
92
+ pipeline = pipeline.tiff({ quality });
93
+ break;
94
+ case 'heif':
95
+ pipeline = pipeline.heif({ quality });
96
+ break;
97
+ }
98
+
99
+ await pipeline.toFile(outputPath);
100
+ },
101
+ 'Image converted successfully'
102
+ );
103
+
104
+ info(`Format: ${inputExt.toUpperCase()} → ${format.toUpperCase()}`);
105
+
106
+ fileResult(inputPath, outputPath, {
107
+ before: inputSize,
108
+ after: getFileSize(outputPath),
109
+ });
110
+
111
+ success('Image converted');
112
+ } catch (err) {
113
+ handleError(err);
114
+ }
115
+ },
116
+ });
@@ -0,0 +1,96 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
4
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
+ import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
6
+ import { fileResult, info, success } from '../../utils/logger';
7
+ import { withSpinner } from '../../utils/progress';
8
+
9
+ export const crop = defineCommand({
10
+ meta: {
11
+ name: 'crop',
12
+ description: 'Crop an image',
13
+ },
14
+ args: {
15
+ input: {
16
+ type: 'positional',
17
+ description: 'Input image file',
18
+ required: true,
19
+ },
20
+ output: {
21
+ type: 'string',
22
+ alias: 'o',
23
+ description: 'Output file path',
24
+ },
25
+ x: {
26
+ type: 'string',
27
+ description: 'Left offset in pixels',
28
+ default: '0',
29
+ },
30
+ y: {
31
+ type: 'string',
32
+ description: 'Top offset in pixels',
33
+ default: '0',
34
+ },
35
+ width: {
36
+ type: 'string',
37
+ alias: 'w',
38
+ description: 'Crop width in pixels',
39
+ required: true,
40
+ },
41
+ height: {
42
+ type: 'string',
43
+ alias: 'h',
44
+ description: 'Crop height in pixels',
45
+ required: true,
46
+ },
47
+ },
48
+ async run({ args }) {
49
+ try {
50
+ const input = requireArg(args.input, 'input');
51
+ const width = requireArg(args.width, 'width');
52
+ const height = requireArg(args.height, 'height');
53
+
54
+ const inputPath = resolvePath(input as string);
55
+ if (!existsSync(inputPath)) {
56
+ throw new FileNotFoundError(input as string);
57
+ }
58
+
59
+ const outputPath = generateOutputPath(inputPath, args.output, '-cropped');
60
+ ensureDir(outputPath);
61
+
62
+ const inputSize = getFileSize(inputPath);
63
+ const left = Number.parseInt(args.x || '0', 10);
64
+ const top = Number.parseInt(args.y || '0', 10);
65
+ const cropWidth = Number.parseInt(width, 10);
66
+ const cropHeight = Number.parseInt(height, 10);
67
+
68
+ // Validate crop area
69
+ const metadata = await sharp(inputPath).metadata();
70
+ if (left + cropWidth > (metadata.width || 0) || top + cropHeight > (metadata.height || 0)) {
71
+ throw new Error('Crop area exceeds image boundaries');
72
+ }
73
+
74
+ await withSpinner(
75
+ `Cropping image (${cropWidth}x${cropHeight} at ${left},${top})...`,
76
+ async () => {
77
+ await sharp(inputPath)
78
+ .extract({ left, top, width: cropWidth, height: cropHeight })
79
+ .toFile(outputPath);
80
+ },
81
+ 'Image cropped successfully'
82
+ );
83
+
84
+ info(`Crop: ${cropWidth}x${cropHeight} at position (${left}, ${top})`);
85
+
86
+ fileResult(inputPath, outputPath, {
87
+ before: inputSize,
88
+ after: getFileSize(outputPath),
89
+ });
90
+
91
+ success('Image cropped');
92
+ } catch (err) {
93
+ handleError(err);
94
+ }
95
+ },
96
+ });
@@ -0,0 +1,89 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { defineCommand } from 'citty';
4
+ import sharp from 'sharp';
5
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
6
+ import { ensureOutputDir, getBasename, resolvePath } from '../../utils/files';
7
+ import { info, success } from '../../utils/logger';
8
+ import { withSpinner } from '../../utils/progress';
9
+ import { c } from '../../utils/style';
10
+
11
+ const FAVICON_SIZES = [16, 32, 48, 64, 128, 180, 192, 512];
12
+
13
+ export const favicon = defineCommand({
14
+ meta: {
15
+ name: 'favicon',
16
+ description: 'Generate favicons from an image',
17
+ },
18
+ args: {
19
+ input: {
20
+ type: 'positional',
21
+ description: 'Input image file (should be square)',
22
+ required: true,
23
+ },
24
+ output: {
25
+ type: 'string',
26
+ alias: 'o',
27
+ description: 'Output directory',
28
+ required: true,
29
+ },
30
+ },
31
+ async run({ args }) {
32
+ try {
33
+ const input = requireArg(args.input, 'input');
34
+ const output = requireArg(args.output, 'output');
35
+
36
+ const inputPath = resolvePath(input as string);
37
+ if (!existsSync(inputPath)) {
38
+ throw new FileNotFoundError(input as string);
39
+ }
40
+
41
+ const outputDir = resolvePath(output);
42
+ ensureOutputDir(`${outputDir}/`);
43
+
44
+ await withSpinner(
45
+ `Generating ${FAVICON_SIZES.length} favicon sizes...`,
46
+ async () => {
47
+ for (const size of FAVICON_SIZES) {
48
+ const outputPath = join(outputDir, `favicon-${size}x${size}.png`);
49
+ await sharp(inputPath)
50
+ .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
51
+ .png()
52
+ .toFile(outputPath);
53
+ }
54
+
55
+ // Also generate apple-touch-icon
56
+ await sharp(inputPath)
57
+ .resize(180, 180, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
58
+ .png()
59
+ .toFile(join(outputDir, 'apple-touch-icon.png'));
60
+
61
+ // Generate favicon.ico (16x16 and 32x32 combined would need ICO format)
62
+ // For simplicity, we'll just create a 32x32 PNG named favicon.png
63
+ await sharp(inputPath)
64
+ .resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
65
+ .png()
66
+ .toFile(join(outputDir, 'favicon.png'));
67
+ },
68
+ 'Favicons generated successfully'
69
+ );
70
+
71
+ info(`Generated ${FAVICON_SIZES.length} sizes: ${FAVICON_SIZES.join(', ')}`);
72
+ info(`Output directory: ${outputDir}`);
73
+
74
+ console.log();
75
+ console.log(c.dim(' Add to your HTML:'));
76
+ console.log(
77
+ ` ${c.white('<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">')}`
78
+ );
79
+ console.log(
80
+ ` ${c.white('<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">')}`
81
+ );
82
+ console.log();
83
+
84
+ success('Favicons generated');
85
+ } catch (err) {
86
+ handleError(err);
87
+ }
88
+ },
89
+ });
@@ -0,0 +1,108 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
4
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
+ import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
6
+ import { fileResult, info, success } from '../../utils/logger';
7
+ import { withSpinner } from '../../utils/progress';
8
+
9
+ const AVAILABLE_FILTERS = ['grayscale', 'sepia', 'blur', 'sharpen', 'negate', 'normalize'];
10
+
11
+ export const filters = defineCommand({
12
+ meta: {
13
+ name: 'filters',
14
+ description: 'Apply filters to an image',
15
+ },
16
+ args: {
17
+ input: {
18
+ type: 'positional',
19
+ description: 'Input image file',
20
+ required: true,
21
+ },
22
+ output: {
23
+ type: 'string',
24
+ alias: 'o',
25
+ description: 'Output file path',
26
+ },
27
+ filter: {
28
+ type: 'string',
29
+ alias: 'f',
30
+ description: `Filter: ${AVAILABLE_FILTERS.join(', ')}`,
31
+ required: true,
32
+ },
33
+ intensity: {
34
+ type: 'string',
35
+ alias: 'i',
36
+ description: 'Filter intensity (for blur, sharpen)',
37
+ default: '3',
38
+ },
39
+ },
40
+ async run({ args }) {
41
+ try {
42
+ const input = requireArg(args.input, 'input');
43
+ const filter = requireArg(args.filter, 'filter').toLowerCase();
44
+
45
+ if (!AVAILABLE_FILTERS.includes(filter)) {
46
+ throw new Error(`Unknown filter: ${filter}. Available: ${AVAILABLE_FILTERS.join(', ')}`);
47
+ }
48
+
49
+ const inputPath = resolvePath(input as string);
50
+ if (!existsSync(inputPath)) {
51
+ throw new FileNotFoundError(input as string);
52
+ }
53
+
54
+ const outputPath = generateOutputPath(inputPath, args.output, `-${filter}`);
55
+ ensureDir(outputPath);
56
+
57
+ const inputSize = getFileSize(inputPath);
58
+ const intensity = Number.parseFloat(args.intensity || '3');
59
+
60
+ await withSpinner(
61
+ `Applying ${filter} filter...`,
62
+ async () => {
63
+ let pipeline = sharp(inputPath);
64
+
65
+ switch (filter) {
66
+ case 'grayscale':
67
+ pipeline = pipeline.grayscale();
68
+ break;
69
+ case 'sepia':
70
+ // Sepia effect using recomb matrix
71
+ pipeline = pipeline.recomb([
72
+ [0.393, 0.769, 0.189],
73
+ [0.349, 0.686, 0.168],
74
+ [0.272, 0.534, 0.131],
75
+ ]);
76
+ break;
77
+ case 'blur':
78
+ pipeline = pipeline.blur(intensity);
79
+ break;
80
+ case 'sharpen':
81
+ pipeline = pipeline.sharpen(intensity);
82
+ break;
83
+ case 'negate':
84
+ pipeline = pipeline.negate();
85
+ break;
86
+ case 'normalize':
87
+ pipeline = pipeline.normalize();
88
+ break;
89
+ }
90
+
91
+ await pipeline.toFile(outputPath);
92
+ },
93
+ 'Filter applied successfully'
94
+ );
95
+
96
+ info(`Filter: ${filter}`);
97
+
98
+ fileResult(inputPath, outputPath, {
99
+ before: inputSize,
100
+ after: getFileSize(outputPath),
101
+ });
102
+
103
+ success('Filter applied to image');
104
+ } catch (err) {
105
+ handleError(err);
106
+ }
107
+ },
108
+ });
@@ -0,0 +1,49 @@
1
+ import { defineCommand } from 'citty';
2
+ import { adjust } from './adjust';
3
+ import { border } from './border';
4
+ import { bulkCompress } from './bulk-compress';
5
+ import { bulkConvert } from './bulk-convert';
6
+ import { bulkResize } from './bulk-resize';
7
+ import { compressCmd } from './compress';
8
+ import { convert } from './convert';
9
+ import { crop } from './crop';
10
+ import { favicon } from './favicon';
11
+ import { filters } from './filters';
12
+ import { resize } from './resize';
13
+ import { rotate } from './rotate';
14
+ import { stripMetadata } from './strip-metadata';
15
+ import { toBase64 } from './to-base64';
16
+ import { watermark } from './watermark';
17
+
18
+ export const image = defineCommand({
19
+ meta: {
20
+ name: 'image',
21
+ description: 'Image manipulation tools',
22
+ },
23
+ subCommands: {
24
+ // Essentials
25
+ compress: compressCmd,
26
+ resize,
27
+ convert,
28
+ crop,
29
+ rotate,
30
+
31
+ // Adjustments & Filters
32
+ adjust,
33
+ filters,
34
+ border,
35
+
36
+ // Content & Metadata
37
+ watermark,
38
+ 'strip-metadata': stripMetadata,
39
+
40
+ // Web & Utils
41
+ favicon,
42
+ 'to-base64': toBase64,
43
+
44
+ // Bulk Operations
45
+ 'bulk-compress': bulkCompress,
46
+ 'bulk-resize': bulkResize,
47
+ 'bulk-convert': bulkConvert,
48
+ },
49
+ });
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
4
+ import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
5
+ import { fileResult, info, success } from '../../utils/logger';
6
+ import { withSpinner } from '../../utils/progress';
7
+
8
+ type FitMode = 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
9
+
10
+ export const resize = defineCommand({
11
+ meta: {
12
+ name: 'resize',
13
+ description: 'Resize an image',
14
+ },
15
+ args: {
16
+ input: {
17
+ type: 'positional',
18
+ description: 'Input image file',
19
+ required: true,
20
+ },
21
+ output: {
22
+ type: 'string',
23
+ alias: 'o',
24
+ description: 'Output file path',
25
+ },
26
+ width: {
27
+ type: 'string',
28
+ alias: 'w',
29
+ description: 'Target width in pixels',
30
+ },
31
+ height: {
32
+ type: 'string',
33
+ alias: 'h',
34
+ description: 'Target height in pixels',
35
+ },
36
+ fit: {
37
+ type: 'string',
38
+ alias: 'f',
39
+ description: 'Fit mode: cover, contain, fill, inside, outside',
40
+ default: 'inside',
41
+ },
42
+ scale: {
43
+ type: 'string',
44
+ alias: 's',
45
+ description: 'Scale factor (e.g., 0.5 for half size)',
46
+ },
47
+ },
48
+ async run({ args }) {
49
+ try {
50
+ const input = requireArg(args.input, 'input');
51
+
52
+ const inputPath = resolvePath(input as string);
53
+ if (!existsSync(inputPath)) {
54
+ throw new FileNotFoundError(input as string);
55
+ }
56
+
57
+ const outputPath = generateOutputPath(inputPath, args.output, '-resized');
58
+ ensureDir(outputPath);
59
+
60
+ const inputSize = getFileSize(inputPath);
61
+ const fit = (args.fit || 'inside') as FitMode;
62
+
63
+ // Lazy load sharp
64
+ const { default: sharp } = await import('sharp');
65
+
66
+ // Get original dimensions
67
+ const metadata = await sharp(inputPath).metadata();
68
+ const originalWidth = metadata.width || 0;
69
+ const originalHeight = metadata.height || 0;
70
+
71
+ let targetWidth: number | undefined;
72
+ let targetHeight: number | undefined;
73
+
74
+ if (args.scale) {
75
+ const scale = Number.parseFloat(args.scale);
76
+ targetWidth = Math.round(originalWidth * scale);
77
+ targetHeight = Math.round(originalHeight * scale);
78
+ } else {
79
+ targetWidth = args.width ? Number.parseInt(args.width, 10) : undefined;
80
+ targetHeight = args.height ? Number.parseInt(args.height, 10) : undefined;
81
+ }
82
+
83
+ if (!targetWidth && !targetHeight) {
84
+ throw new Error('Specify --width, --height, or --scale');
85
+ }
86
+
87
+ await withSpinner(
88
+ 'Resizing image...',
89
+ async () => {
90
+ await sharp(inputPath)
91
+ .resize(targetWidth, targetHeight, { fit, withoutEnlargement: true })
92
+ .toFile(outputPath);
93
+ },
94
+ 'Image resized successfully'
95
+ );
96
+
97
+ const outputMetadata = await sharp(outputPath).metadata();
98
+ info(`${originalWidth}x${originalHeight} → ${outputMetadata.width}x${outputMetadata.height}`);
99
+
100
+ fileResult(inputPath, outputPath, {
101
+ before: inputSize,
102
+ after: getFileSize(outputPath),
103
+ });
104
+
105
+ success('Image resized');
106
+ } catch (err) {
107
+ handleError(err);
108
+ }
109
+ },
110
+ });