@nathievzm/lumi 1.1.5 → 1.2.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/src/index.ts CHANGED
@@ -1,130 +1,120 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { readdir } from 'node:fs/promises'
4
3
  import { basename, extname } from 'node:path'
5
4
  import { exit } from 'node:process'
6
5
 
7
6
  import { intro, log, note, outro, spinner } from '@clack/prompts'
8
- import { Temporal } from '@js-temporal/polyfill'
9
7
  import boxen from 'boxen'
10
8
  import pLimit from 'p-limit'
11
9
  import color from 'picocolors'
12
- import updateNotifier from 'update-notifier'
13
10
 
14
11
  import { cli } from '@/args'
15
- import { getMessage } from '@/error'
16
- import { getInput, getOutput, prepare } from '@/folder'
12
+ import { FolderError, ImageError, LumiError } from '@/error'
13
+ import { getInput, getOutput, prepare, readFiles } from '@/folder'
17
14
  import { getExtensions, getImages, getWidthAndHeight, resize } from '@/image'
15
+ import { notifyUpdate } from '@/update'
18
16
 
19
17
  import pkg from '../package.json' with { type: 'json' }
20
18
 
21
- const notifier = updateNotifier({ pkg })
22
- notifier.notify({
23
- boxenOptions: { borderColor: 'magenta', borderStyle: 'round', padding: 1 }
24
- })
19
+ try {
20
+ void notifyUpdate(pkg)
25
21
 
26
- console.clear()
22
+ console.clear()
27
23
 
28
- const banner = boxen('lumi', {
29
- backgroundColor: 'magenta',
30
- borderColor: 'magenta',
31
- borderStyle: 'round',
32
- padding: { bottom: 2, left: 15, right: 15, top: 2 },
33
- textAlignment: 'center'
34
- })
24
+ const banner = boxen('lumi', {
25
+ backgroundColor: 'magenta',
26
+ borderColor: 'magenta',
27
+ borderStyle: 'round',
28
+ padding: { bottom: 2, left: 15, right: 15, top: 2 },
29
+ textAlignment: 'center'
30
+ })
35
31
 
36
- console.log(banner, '\n')
32
+ console.log(banner, '\n')
37
33
 
38
- intro(color.magenta(`welcome to lumi v${pkg.version} 🩷`))
34
+ intro(color.magenta(`welcome to lumi v${pkg.version} 🩷`))
39
35
 
40
- const input = getInput(cli.input)
41
- const output = getOutput(cli.output)
42
- await prepare(output)
36
+ const input = getInput(cli.input)
37
+ const output = getOutput(cli.output)
38
+ await prepare(output)
43
39
 
44
- let allFiles: string[] = []
40
+ const allFiles = await readFiles(input, cli.recursive)
41
+ const images = getImages(allFiles, input, output)
45
42
 
46
- try {
47
- allFiles = await readdir(input, { recursive: cli.recursive })
48
- } catch (error) {
49
- const message = getMessage(error)
50
- log.error(`yikes! couldn't read the input folder: ${message} 😢`)
51
- outro('please check your input path and try again 👋')
52
- exit(1)
53
- }
43
+ if (images.length === 0) {
44
+ const hint = cli.recursive ? '' : ' (try adding the --recursive flag to check subfolders)'
45
+ throw new ImageError(`no valid images found in the input folder 😭${hint}`)
46
+ }
54
47
 
55
- const images = getImages(allFiles)
48
+ note(`found ${color.magenta(images.length)} images to process! 🚀`)
56
49
 
57
- if (images.length === 0) {
58
- log.error('yikes! no valid images found in the input folder 😭')
59
- outro('please add some images to the input folder and try again 👋')
60
- exit(1)
61
- }
50
+ const { width, height } = await getWidthAndHeight(cli.width, cli.height)
51
+ const extensions = await getExtensions(images, cli.format)
62
52
 
63
- note(`found ${color.magenta(images.length)} images to process! 🚀`)
53
+ const limit = pLimit({ concurrency: cli.limit || 10, rejectOnClear: true })
64
54
 
65
- let dimensions = { height: 0, width: 0 }
55
+ const spin = spinner()
56
+ spin.start(`processing: ${color.magenta(0)}/${color.magenta(images.length)} images 🔃`)
66
57
 
67
- try {
68
- dimensions = await getWidthAndHeight(cli.width, cli.height)
69
- } catch (error: unknown) {
70
- const message = getMessage(error)
71
- log.error(message)
72
- outro('please check your input dimensions and try again 🛠️')
73
- exit(1)
74
- }
58
+ let processed = 0
75
59
 
76
- const { width, height } = dimensions
60
+ const startTime = performance.now()
77
61
 
78
- const extensions = await getExtensions(images, cli.format)
79
- const limit = pLimit({ concurrency: cli.limit || 10, rejectOnClear: true })
62
+ const promises = images.map(image =>
63
+ limit(async () => {
64
+ const ext = extname(image)
65
+ const name = basename(image, ext)
66
+ const extension = extensions['default'] ?? extensions[ext] ?? '.png'
80
67
 
81
- const spin = spinner()
82
- spin.start(`processing: ${color.magenta(0)}/${color.magenta(images.length)} images 🔃`)
68
+ await resize({ extension, height, image, input, name, output, width })
83
69
 
84
- let processed = 0
85
-
86
- const startTime = Temporal.Now.instant()
87
-
88
- const promises = images.map(image =>
89
- limit(async () => {
90
- const ext = extname(image)
91
- const name = basename(image, ext)
92
- const extension = extensions['default'] ?? extensions[ext] ?? '.png'
93
-
94
- await resize({ extension, height, image, input, name, output, width })
70
+ processed++
71
+ spin.message(`processing: ${color.green(processed)}/${color.magenta(images.length)} images 🔃`)
72
+ })
73
+ )
95
74
 
96
- processed++
97
- spin.message(`processing: ${color.green(processed)}/${color.magenta(images.length)} images 🔃`)
98
- })
99
- )
75
+ log.message()
100
76
 
101
- log.message()
77
+ const result = await Promise.allSettled(promises)
102
78
 
103
- const result = await Promise.allSettled(promises)
79
+ const endTime = performance.now()
80
+ const duration = ((endTime - startTime) / 1000).toFixed(2)
104
81
 
105
- const endTime = Temporal.Now.instant()
106
- const duration = startTime.until(endTime).total('seconds').toFixed(2)
82
+ let outroMessage = ''
107
83
 
108
- let outroMessage = ''
84
+ if (result.some(pr => pr.status === 'rejected')) {
85
+ spin.error(
86
+ `yikes! finished with errors. processed ${color.red(processed)}/${color.red(images.length)} images in ${color.yellow(duration)} seconds 😢`
87
+ )
109
88
 
110
- if (result.some(pr => pr.status === 'rejected')) {
111
- spin.error(
112
- `yikes! finished with errors. processed ${color.red(processed)}/${color.red(images.length)} images in ${color.yellow(duration)} seconds 😢`
113
- )
89
+ for (const pr of result) {
90
+ if (pr.status === 'fulfilled') {
91
+ continue
92
+ }
114
93
 
115
- for (const pr of result) {
116
- if (pr.status === 'fulfilled') {
117
- continue
94
+ const message = LumiError.getMessage(pr.reason)
95
+ log.error(color.red(message))
118
96
  }
119
97
 
120
- const message = getMessage(pr.reason)
121
- log.error(message)
98
+ outroMessage = 'please check your input files and try again 🛠️'
99
+ } else {
100
+ spin.stop(
101
+ `yay! ${color.green(images.length)} images processed in ${color.green(duration)} seconds! \u26A1\uFE0F`
102
+ )
103
+ outroMessage = 'bye 👋'
122
104
  }
123
105
 
124
- outroMessage = 'please check your input files and try again 🛠️'
125
- } else {
126
- spin.stop(`yay! ${color.green(images.length)} images processed in ${color.green(duration)} seconds! \u26A1\uFE0F`)
127
- outroMessage = 'bye 👋'
128
- }
106
+ outro(color.magenta(outroMessage))
107
+ } catch (error) {
108
+ const message = LumiError.getMessage(error)
109
+
110
+ if (error instanceof FolderError) {
111
+ log.error(color.red(`folder issue: ${message}`))
112
+ } else if (error instanceof ImageError) {
113
+ log.error(color.red(`image issue: ${message}`))
114
+ } else {
115
+ log.error(color.red(`unexpected anomaly: ${message} 👽`))
116
+ }
129
117
 
130
- outro(color.magenta(outroMessage))
118
+ outro(color.magenta('please check your configuration and try again 👋'))
119
+ exit(1)
120
+ }
package/src/lib/args.ts CHANGED
@@ -5,7 +5,7 @@ const { values } = parseArgs({
5
5
  args: Bun.argv.slice(2),
6
6
  options: {
7
7
  /**
8
- * Global output format. Defaults to `FORMAT` env var.
8
+ * Global output format. Defaults to the `FORMAT` environment variable.
9
9
  */
10
10
  format: {
11
11
  default: Bun.env.FORMAT,
@@ -13,7 +13,7 @@ const { values } = parseArgs({
13
13
  type: 'string'
14
14
  },
15
15
  /**
16
- * Target height. Defaults to `HEIGHT` env var.
16
+ * Target height. Defaults to the `HEIGHT` environment variable.
17
17
  */
18
18
  height: {
19
19
  default: Bun.env.HEIGHT,
@@ -21,7 +21,7 @@ const { values } = parseArgs({
21
21
  type: 'string'
22
22
  },
23
23
  /**
24
- * Input directory path. Defaults to `INPUT_FOLDER` env var.
24
+ * Input directory path. Defaults to the `INPUT_FOLDER` environment variable.
25
25
  */
26
26
  input: {
27
27
  default: Bun.env.INPUT_FOLDER,
@@ -29,7 +29,7 @@ const { values } = parseArgs({
29
29
  type: 'string'
30
30
  },
31
31
  /**
32
- * Concurrent processing limit. Defaults to `LIMIT` env var.
32
+ * Concurrent processing limit. Defaults to the `LIMIT` environment variable.
33
33
  */
34
34
  limit: {
35
35
  default: Bun.env.LIMIT,
@@ -37,7 +37,7 @@ const { values } = parseArgs({
37
37
  type: 'string'
38
38
  },
39
39
  /**
40
- * Output directory path. Defaults to `OUTPUT_FOLDER` env var.
40
+ * Output directory path. Defaults to the `OUTPUT_FOLDER` environment variable.
41
41
  */
42
42
  output: {
43
43
  default: Bun.env.OUTPUT_FOLDER,
@@ -45,10 +45,10 @@ const { values } = parseArgs({
45
45
  type: 'string'
46
46
  },
47
47
  /**
48
- * Whether to process the input directory recursively. Defaults to `RECURSIVE` env var.
48
+ * Whether to process the input directory recursively. Defaults to the `RECURSIVE` environment variable.
49
49
  */
50
50
  recursive: {
51
- default: Boolean(Bun.env.RECURSIVE),
51
+ default: Bun.env.RECURSIVE === 'true',
52
52
  short: 'r',
53
53
  type: 'boolean'
54
54
  },
@@ -60,7 +60,7 @@ const { values } = parseArgs({
60
60
  type: 'string'
61
61
  },
62
62
  /**
63
- * Target width. Defaults to `WIDTH` env var.
63
+ * Target width. Defaults to the `WIDTH` environment variable.
64
64
  */
65
65
  width: {
66
66
  default: Bun.env.WIDTH,
@@ -83,6 +83,7 @@ const { input, output, format, recursive } = values
83
83
 
84
84
  /**
85
85
  * The consolidated CLI configuration object.
86
+ *
86
87
  * Contains resolved paths, dimensions, and processing limits.
87
88
  */
88
89
  export const cli = { format, height, input, limit, output, recursive, width }
package/src/lib/error.ts CHANGED
@@ -1,21 +1,68 @@
1
1
  /**
2
- * Safely extracts a human-readable error message from an unknown error.
2
+ * Base error class for all Lumi-specific errors.
3
3
  *
4
- * Designed for use in catch blocks where the error type is unknown,
5
- * ensuring a consistent string output for logging or user feedback.
6
- *
7
- * @param error - The caught error to process.
8
- *
9
- * @returns The error message string, or a fallback message if the type is unrecognized.
4
+ * Extends the built-in `Error` class and allows for an optional underlying cause.
10
5
  */
11
- export const getMessage = (error: unknown) => {
12
- if (error instanceof Error) {
13
- return error.message
6
+ export class LumiError extends Error {
7
+ /**
8
+ * Creates a new `LumiError` instance.
9
+ *
10
+ * @param message - The error message.
11
+ * @param error - The underlying error or cause.
12
+ */
13
+ constructor(message: string, error?: unknown) {
14
+ super(message, { cause: error })
15
+ this.name = 'LumiError'
14
16
  }
15
17
 
16
- if (typeof error === 'string') {
17
- return error
18
+ /**
19
+ * Extracts a human-readable message from an unknown error object.
20
+ *
21
+ * @param error - The error to extract the message from.
22
+ *
23
+ * @returns The error message, or a generic string if the type is unknown.
24
+ */
25
+ static getMessage(error: unknown) {
26
+ if (error instanceof Error) {
27
+ return error.message
28
+ }
29
+
30
+ if (typeof error === 'string') {
31
+ return error
32
+ }
33
+
34
+ return 'an unknown error occurred'
18
35
  }
36
+ }
19
37
 
20
- return 'an unknown error occurred'
38
+ /**
39
+ * Error thrown when an issue occurs with folder operations.
40
+ */
41
+ export class FolderError extends LumiError {
42
+ /**
43
+ * Creates a new `FolderError` instance.
44
+ *
45
+ * @param message - The error message.
46
+ * @param error - The underlying error or cause.
47
+ */
48
+ constructor(message: string, error?: unknown) {
49
+ super(message, { cause: error })
50
+ this.name = 'FolderError'
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Error thrown when an issue occurs with image processing operations.
56
+ */
57
+ export class ImageError extends LumiError {
58
+ /**
59
+ * Creates a new `ImageError` instance.
60
+ *
61
+ * @param message - The error message.
62
+ * @param error - The underlying error or cause.
63
+ */
64
+ constructor(message: string, error?: unknown) {
65
+ super(message, { cause: error })
66
+ this.name = 'ImageError'
67
+ }
21
68
  }
package/src/lib/folder.ts CHANGED
@@ -1,22 +1,25 @@
1
- import { exists, mkdir } from 'node:fs/promises'
1
+ import { exists, mkdir, readdir } from 'node:fs/promises'
2
2
  import { resolve, sep } from 'node:path'
3
3
  import { cwd } from 'node:process'
4
4
 
5
5
  import { log } from '@clack/prompts'
6
6
  import color from 'picocolors'
7
7
 
8
+ import { FolderError } from './error'
9
+
8
10
  /**
9
11
  * Resolves the input directory path.
10
12
  *
11
13
  * If a path is provided via CLI arguments, it resolves to that path and logs the action.
12
14
  * Otherwise, it defaults to the current working directory.
13
15
  *
14
- * @param input - An optional input path provided via CLI arguments.
16
+ * @param input - An input path provided via CLI arguments.
15
17
  *
16
18
  * @returns The resolved input directory path.
17
19
  */
18
20
  export const getInput = (input?: string) => {
19
21
  if (input !== undefined && input !== '') {
22
+ guard(cwd(), input)
20
23
  log.info(`input folder provided: ${color.cyan(input)} 📂`)
21
24
  return input
22
25
  }
@@ -33,12 +36,13 @@ export const getInput = (input?: string) => {
33
36
  * If a path is provided via CLI arguments, it resolves to that path and logs the action.
34
37
  * Otherwise, it defaults to an 'output' directory in the current working directory.
35
38
  *
36
- * @param output - An optional output path provided via CLI arguments.
39
+ * @param output - An output path provided via CLI arguments.
37
40
  *
38
41
  * @returns The resolved output directory path.
39
42
  */
40
43
  export const getOutput = (output?: string) => {
41
44
  if (output !== undefined && output !== '') {
45
+ guard(cwd(), output)
42
46
  log.info(`output folder provided: ${color.cyan(output)} 📂`)
43
47
  return output
44
48
  }
@@ -60,13 +64,34 @@ export const getOutput = (output?: string) => {
60
64
  * @returns A promise that resolves when the directory is verified or successfully created.
61
65
  */
62
66
  export const prepare = async (output: string) => {
63
- const outputExists = await exists(output)
67
+ try {
68
+ const outputExists = await exists(output)
69
+
70
+ if (!outputExists) {
71
+ await mkdir(output, { recursive: true })
72
+ }
64
73
 
65
- if (!outputExists) {
66
- await mkdir(output, { recursive: true })
74
+ log.info(`output folder ready: ${color.cyan(output)} ✨`, { spacing: 0 })
75
+ } catch (error) {
76
+ throw new FolderError(`could not prepare the output folder: ${output}`, error)
67
77
  }
78
+ }
68
79
 
69
- log.info(`output folder ready: ${color.cyan(output)} ✅`, { spacing: 0 })
80
+ /**
81
+ * Reads the contents of a directory.
82
+ *
83
+ * @param input - The path to the directory to read.
84
+ * @param recursive - Whether to read the directory recursively. Defaults to `false`.
85
+ *
86
+ * @returns A promise that resolves to an array of file names or relative paths.
87
+ * @throws { FolderError } If the directory cannot be read.
88
+ */
89
+ export const readFiles = async (input: string, recursive = false) => {
90
+ try {
91
+ return await readdir(input, { recursive })
92
+ } catch (error) {
93
+ throw new FolderError(`could not read the input folder: ${input}`, error)
94
+ }
70
95
  }
71
96
 
72
97
  /**
@@ -76,15 +101,25 @@ export const prepare = async (output: string) => {
76
101
  * @param path - The target path to verify.
77
102
  *
78
103
  * @returns `true` if the target path is securely contained within the base folder.
79
- * @throws { Error } If the target path resolves outside the base folder, indicating a potential path traversal attempt.
104
+ * @throws { FolderError } If the target path resolves outside the base folder, indicating a potential path traversal
105
+ * attempt.
80
106
  */
81
107
  export const guard = (folder: string, path: string) => {
108
+ if (path.includes('\0') || folder.includes('\0')) {
109
+ throw new FolderError('path traversal detected 🚨')
110
+ }
111
+
82
112
  const resolved = resolve(folder)
83
113
  const resolvedPath = resolve(path)
114
+
115
+ if (resolved === resolvedPath) {
116
+ return true
117
+ }
118
+
84
119
  const normalized = resolved.endsWith(sep) ? resolved : resolved + sep
85
120
 
86
121
  if (!resolvedPath.startsWith(normalized)) {
87
- throw new Error('path traversal detected 🚫')
122
+ throw new FolderError('path traversal detected 🚨')
88
123
  }
89
124
 
90
125
  return true
package/src/lib/image.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { extname, join } from 'node:path'
1
+ import { extname, join, resolve, sep } from 'node:path'
2
2
 
3
3
  import { type Option } from '@clack/prompts'
4
4
  import imageExtensions from 'image-extensions'
5
- import sharp, { type AvailableFormatInfo } from 'sharp'
5
+ import { type AvailableFormatInfo, type default as sharpType } from 'sharp'
6
6
 
7
+ import { ImageError } from './error'
7
8
  import { guard } from './folder'
8
9
  import { askExtensions, askWidthAndHeight } from './prompt'
9
10
 
@@ -41,9 +42,32 @@ interface ResizeParams {
41
42
  readonly extension: string
42
43
  }
43
44
 
45
+ /**
46
+ * Array of supported input image format extensions prefixed with a dot (e.g., '.jpg', '.png').
47
+ */
44
48
  const inputFormats = imageExtensions.map(format => `.${format}`)
49
+
50
+ /**
51
+ * Set of valid input image extensions for optimized lookup.
52
+ */
45
53
  const validExtensions = new Set(inputFormats)
46
54
 
55
+ /**
56
+ * Cached promise for the dynamically imported sharp module.
57
+ */
58
+ let sharpPromise: Promise<{ default: typeof sharpType }> | undefined = undefined
59
+
60
+ /**
61
+ * Helper to get the cached sharp module promise.
62
+ *
63
+ * @returns A promise resolving to the default export of the sharp module.
64
+ */
65
+ const getSharp = async () => {
66
+ sharpPromise ??= import('sharp')
67
+ const { default: sharp } = await sharpPromise
68
+ return sharp
69
+ }
70
+
47
71
  /**
48
72
  * A type guard to verify if an unknown value conforms to the `AvailableFormatInfo` structure from Sharp.
49
73
  *
@@ -59,8 +83,10 @@ const isFormatInfo = (value: unknown): value is AvailableFormatInfo =>
59
83
  *
60
84
  * @returns An array of prompt-compatible `Option` objects representing the supported output formats.
61
85
  */
62
- const getSharpFormats = () => {
86
+ const getSharpFormats = async () => {
87
+ const sharp = await getSharp()
63
88
  const sharpFormats = Object.values(sharp.format).filter(format => isFormatInfo(format))
89
+
64
90
  const formats: Option<string>[] = sharpFormats
65
91
  .filter(format => format.output.file)
66
92
  .map(format => ({ label: format.id, value: `.${format.id}` }))
@@ -69,17 +95,48 @@ const getSharpFormats = () => {
69
95
  }
70
96
 
71
97
  /**
72
- * Filters an array of file paths, returning only those with recognized image extensions.
98
+ * Determines whether a given file is a valid, processable image.
99
+ *
100
+ * A file is considered processable if it has a valid image extension and is not
101
+ * already located within the output directory.
73
102
  *
74
- * @param files - A readonly array of file paths or filenames to filter.
103
+ * @param file - The name or relative path of the file to check.
104
+ * @param input - The absolute or relative path to the input directory.
105
+ * @param output - The absolute or relative path to the output directory.
75
106
  *
76
- * @returns An array containing only the file paths that correspond to supported image formats.
107
+ * @returns `true` if the file is a processable image, otherwise `false`.
77
108
  */
78
- export const getImages = (files: readonly string[]) => {
79
- const images = files.filter(file => {
80
- const ext = extname(file)
81
- return validExtensions.has(ext.toLowerCase())
82
- })
109
+ const isProcessable = (file: string, input: string, output: string) => {
110
+ const ext = extname(file)
111
+ const isImage = validExtensions.has(ext.toLowerCase())
112
+ const isInOutput = join(input, file).startsWith(output)
113
+
114
+ return isImage && !isInOutput
115
+ }
116
+
117
+ /**
118
+ * Filters a list of files to identify valid images that are not already present in the output directory.
119
+ *
120
+ * @param files - A readonly array of file paths to filter.
121
+ * @param input - The absolute or relative path to the source directory containing the input files.
122
+ * @param output - The absolute or relative path to the destination directory for the processed images.
123
+ *
124
+ * @returns An array of string paths representing valid, unprocessed images.
125
+ */
126
+ export const getImages = (files: readonly string[], input: string, output: string) => {
127
+ const resolvedInput = resolve(input)
128
+ const resolvedOutput = resolve(output)
129
+ const normalizedOutput = resolvedOutput.endsWith(sep) ? resolvedOutput : resolvedOutput + sep
130
+
131
+ const images: string[] = []
132
+
133
+ for (const file of files) {
134
+ if (!isProcessable(file, resolvedInput, normalizedOutput)) {
135
+ continue
136
+ }
137
+
138
+ images.push(file)
139
+ }
83
140
 
84
141
  return images
85
142
  }
@@ -94,7 +151,7 @@ export const getImages = (files: readonly string[]) => {
94
151
  * @param height - The target height parsed from CLI arguments.
95
152
  *
96
153
  * @returns A promise resolving to an object containing the validated `width` and `height`.
97
- * @throws { Error } If the provided or prompted dimensions exceed Sharp's 16383 pixel limit.
154
+ * @throws { ImageError } If the provided or prompted dimensions exceed Sharp's 16383 pixel limit.
98
155
  */
99
156
  export const getWidthAndHeight = (width: number, height: number) => {
100
157
  const notWidth = isNaN(width) || width <= 0
@@ -105,7 +162,7 @@ export const getWidthAndHeight = (width: number, height: number) => {
105
162
  }
106
163
 
107
164
  if (width > 16_383 || height > 16_383) {
108
- throw new Error('dimensions must be less than 16384 pixels 🚫')
165
+ throw new ImageError('dimensions must be less than 16384 pixels 🚫')
109
166
  }
110
167
 
111
168
  return Promise.resolve({ height, width })
@@ -119,16 +176,17 @@ export const getWidthAndHeight = (width: number, height: number) => {
119
176
  * and prompts the user to map each original extension to a specific output format interactively.
120
177
  *
121
178
  * @param images - A readonly array of input image filenames to analyze for unique extensions.
122
- * @param format - An optional global output format string provided via CLI arguments.
179
+ * @param format - A global output format string provided via CLI arguments.
123
180
  *
124
181
  * @returns A promise resolving to a record that maps input extensions (or 'default') to their chosen output formats.
125
182
  */
126
- export const getExtensions = (images: readonly string[], format?: string) => {
183
+ export const getExtensions = async (images: readonly string[], format?: string) => {
127
184
  if (format !== undefined && format !== '') {
128
- return Promise.resolve({ default: format } as Record<string, string>)
185
+ return { default: format } as Record<string, string>
129
186
  }
130
187
 
131
188
  const extensionsSet = new Set<string>()
189
+
132
190
  for (const image of images) {
133
191
  const ext = image ? extname(image) : ''
134
192
  if (ext) {
@@ -137,7 +195,7 @@ export const getExtensions = (images: readonly string[], format?: string) => {
137
195
  }
138
196
  const extensions = [...extensionsSet]
139
197
 
140
- const formats = getSharpFormats()
198
+ const formats = await getSharpFormats()
141
199
  return askExtensions(extensions, formats)
142
200
  }
143
201
 
@@ -150,7 +208,8 @@ export const getExtensions = (images: readonly string[], format?: string) => {
150
208
  * @param params - The configuration parameters dictating the resize and conversion operations.
151
209
  *
152
210
  * @returns A promise that resolves to a descriptive success message upon completion.
153
- * @throws { Error } If the image processing fails or if a path traversal attempt is detected during output resolution.
211
+ * @throws { ImageError } If the image processing fails or if a path traversal attempt is detected during output
212
+ * resolution.
154
213
  */
155
214
  export const resize = async (params: ResizeParams) => {
156
215
  const { image, input, output, width, height, name, extension } = params
@@ -159,14 +218,17 @@ export const resize = async (params: ResizeParams) => {
159
218
  const inputPath = join(input, image)
160
219
  const outputPath = join(output, `${name}${extension}`)
161
220
 
221
+ guard(input, inputPath)
162
222
  guard(output, outputPath)
163
223
 
224
+ const sharp = await getSharp()
225
+
164
226
  await sharp(inputPath, { animated: true })
165
227
  .resize(width, height, { background: 'transparent', fit: 'contain' })
166
228
  .toFile(outputPath)
167
229
 
168
230
  return `processed and saved: ${name}${extension} `
169
- } catch (error: any) {
170
- throw new Error(`error processing ${image} `, { cause: error })
231
+ } catch (error: unknown) {
232
+ throw new ImageError(`error processing ${image} `, error)
171
233
  }
172
234
  }