@nathievzm/lumi 1.1.2 → 1.1.5

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/.Jules/bolt.md ADDED
@@ -0,0 +1,8 @@
1
+ ## 2026-05-12 - [Optimizing Array & Set Operations]
2
+
3
+ - **Learning:** In hot paths handling large numbers of files, using chained array methods like `.flatMap()`,
4
+ `new Set()`, spread operators, and `.filter()` creates unnecessary intermediate arrays and memory allocations.
5
+ Furthermore, repeatedly creating static Sets inside functions adds unnecessary O(N) overhead per call.
6
+ - **Action:** When iterating over potentially large lists (like file paths), favor a single-pass `for...of` loop over
7
+ chained array methods to minimize memory footprint. Always pull static Set creation outside of function scopes to
8
+ initialize them once at the module level.
@@ -0,0 +1,20 @@
1
+ ## 2026-05-12 - Added error output for failed images
2
+
3
+ - **Learning:** Users need to know exactly which images failed during batch processing. An overall error message is not
4
+ actionable.
5
+ - **Action:** Always output detailed errors for individual batch items when a batch process fails, even if it adds to
6
+ terminal noise, because the user needs to correct specific items.
7
+
8
+ ## 2026-05-13 - Friendly CLI Errors
9
+
10
+ - **Learning:** Raw stack traces from unhandled Promise rejections (like `readdir` on a non-existent directory) are
11
+ intimidating for CLI users.
12
+ - **Action:** Always wrap file I/O operations in `try...catch` blocks and use UI logging tools (like `@clack/prompts`)
13
+ to provide actionable, human-readable error messages before gracefully exiting.
14
+
15
+ ## 2026-05-14 - Default CLI Prompt Values
16
+
17
+ - **Learning:** When prompting users for repeated or common file conversions, failing to default to the original format
18
+ creates unnecessary friction. Users expect smart defaults that save keystrokes.
19
+ - **Action:** Always set an `initialValue` in CLI selection prompts (`@clack/prompts`) where a logical default exists,
20
+ such as defaulting to a file's original extension during conversion options.
@@ -0,0 +1,19 @@
1
+ ## 2026-05-12 - Fix Missing Input Length Limits (DoS Risk)
2
+
3
+ - **Vulnerability:** Denial of Service (DoS) vulnerability via resource exhaustion caused by unconstrained width and
4
+ height parameters during image resizing with Sharp.
5
+ - **Learning:** Tools handling image processing can easily be abused to consume excessive memory if user-provided
6
+ dimensions are unchecked, potentially crashing the Node.js/Bun process.
7
+ - **Prevention:** Implement strict length and dimension validation boundaries for all user input governing asset
8
+ generation, catching errors gracefully without leaking system details.
9
+
10
+ ## 2024-05-14 - Fix Path Traversal in Image Output
11
+
12
+ - **Vulnerability:** A path traversal vulnerability existed in `src/lib/image.ts`. The output file path was constructed
13
+ by blindly concatenating user input (`cli.format` passed as `extension`) using `join`. An attacker could pass a format
14
+ string like `/../../../tmp/evil.png` to write files to arbitrary locations outside the intended output directory.
15
+ - **Learning:** We must not blindly trust user input that influences file paths, especially file extensions or format
16
+ modifiers. When working with Node.js `path.join`, it resolves relative segments, which can escape the current
17
+ directory.
18
+ - **Prevention:** Always validate constructed file paths. Before performing any file operation, resolve the final output
19
+ path and check if it strictly starts with the resolved target directory path to prevent path traversals.
@@ -1,3 +1,3 @@
1
- {
2
- "extends": ["@commitlint/config-conventional"]
3
- }
1
+ {
2
+ "extends": ["@commitlint/config-conventional"]
3
+ }
@@ -1,30 +1,30 @@
1
- name: auto assign author
2
-
3
- on:
4
- issues:
5
- types: [opened]
6
- pull_request:
7
- types: [opened]
8
-
9
- jobs:
10
- assign:
11
- runs-on: ubuntu-latest
12
- permissions:
13
- issues: write
14
- pull-requests: write
15
- steps:
16
- - name: assign the user who opened the ticket or pr
17
- uses: actions/github-script@v7
18
- with:
19
- github-token: ${{ secrets.LUMI_AUTOASSIGN_TOKEN }}
20
- script: |
21
- // grab the username of the person who triggered the action
22
- const author = context.actor
23
-
24
- // tell github's api to assign the ticket to that username
25
- await github.rest.issues.addAssignees({
26
- owner: context.repo.owner,
27
- repo: context.repo.repo,
28
- issue_number: context.issue.number,
29
- assignees: [author]
30
- })
1
+ name: auto assign author
2
+
3
+ on:
4
+ issues:
5
+ types: [opened]
6
+ pull_request:
7
+ types: [opened]
8
+
9
+ jobs:
10
+ assign:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ issues: write
14
+ pull-requests: write
15
+ steps:
16
+ - name: assign the user who opened the ticket or pr
17
+ uses: actions/github-script@v7
18
+ with:
19
+ github-token: ${{ secrets.LUMI_AUTOASSIGN_TOKEN }}
20
+ script: |
21
+ // grab the username of the person who triggered the action
22
+ const author = context.actor
23
+
24
+ // tell github's api to assign the ticket to that username
25
+ await github.rest.issues.addAssignees({
26
+ owner: context.repo.owner,
27
+ repo: context.repo.repo,
28
+ issue_number: context.issue.number,
29
+ assignees: [author]
30
+ })
package/.oxlintrc.json CHANGED
@@ -24,7 +24,7 @@
24
24
  "no-continue": "off",
25
25
  "require-param-type": "off",
26
26
  "require-returns-type": "off",
27
- "max-dependencies": ["warn", { "max": 12 }]
27
+ "max-dependencies": ["warn", { "max": 16 }]
28
28
  },
29
29
  "env": {
30
30
  "builtin": true
@@ -1,12 +1,12 @@
1
- {
2
- "oxc.fmt.configPath": ".oxfmtrc.json",
3
- "editor.defaultFormatter": "oxc.oxc-vscode",
4
- "editor.formatOnSave": true,
5
- "oxc.enable.oxlint": true,
6
- "oxc.enable.oxfmt": true,
7
- "editor.codeActionsOnSave": {
8
- "source.fixAll.oxc": "always"
9
- },
10
- "files.insertFinalNewline": false,
11
- "files.trimTrailingWhitespace": true
12
- }
1
+ {
2
+ "oxc.fmt.configPath": ".oxfmtrc.json",
3
+ "editor.defaultFormatter": "oxc.oxc-vscode",
4
+ "editor.formatOnSave": true,
5
+ "oxc.enable.oxlint": true,
6
+ "oxc.enable.oxfmt": true,
7
+ "editor.codeActionsOnSave": {
8
+ "source.fixAll.oxc": "always"
9
+ },
10
+ "files.insertFinalNewline": false,
11
+ "files.trimTrailingWhitespace": true
12
+ }
package/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [1.1.5](https://github.com/nathievzm/lumi/compare/v1.1.4...v1.1.5) (2026-05-14)
2
+
3
+ ### Bug Fixes
4
+
5
+ - restrict image dimensions to prevent resource exhaustion
6
+ ([40e65f3](https://github.com/nathievzm/lumi/commit/40e65f34f04f22cea1d7f543beddc6d45c66d87a))
7
+
8
+ ### Features
9
+
10
+ - provide smart default for CLI format selection prompt
11
+ ([7e31044](https://github.com/nathievzm/lumi/commit/7e31044e841610b660388258a5cc971246ad2884))
12
+ - **ux:** improve error handling for missing input folders
13
+ ([b4ad267](https://github.com/nathievzm/lumi/commit/b4ad26711348c3f0c38f990531c4f2ea0b3ad0ff))
14
+
15
+ ### Performance Improvements
16
+
17
+ - improve readability of code suggested by jules
18
+ ([aa11125](https://github.com/nathievzm/lumi/commit/aa11125156cec42f97a3e3ab08cdf98fb63aa432))
19
+ - **index:** use getMessage function to get the error message
20
+ ([5571325](https://github.com/nathievzm/lumi/commit/557132529d782af777fa9df3bac03b1fa800f48c))
21
+ - remove oxlint disabled comments by jules
22
+ ([7f6ece6](https://github.com/nathievzm/lumi/commit/7f6ece6379c944504e08865a34bfd0c28819566c))
23
+
24
+ ## [1.1.4](https://github.com/nathievzm/lumi/compare/v1.1.3...v1.1.4) (2026-05-13)
25
+
26
+ ## [1.1.3](https://github.com/nathievzm/lumi/compare/v1.1.2...v1.1.3) (2026-05-13)
27
+
28
+ ### Features
29
+
30
+ - add update notifier to notify when there'a a new version ([#67](https://github.com/nathievzm/lumi/issues/67))
31
+ ([b567f03](https://github.com/nathievzm/lumi/commit/b567f03b3c6617b18bdc34587d65055c5726bcfc))
32
+
1
33
  ## [1.1.2](https://github.com/nathievzm/lumi/compare/v1.1.1...v1.1.2) (2026-05-13)
2
34
 
3
35
  ### Bug Fixes
package/lefthook.yml CHANGED
@@ -1,24 +1,24 @@
1
- pre-commit:
2
- # running all checks at the same time for maximum speed ⚡
3
- parallel: true
4
- jobs:
5
- - name: format
6
- glob: '*.{ts,js,json,yml,yaml,md}'
7
- run: bun fmt {staged_files} && git add {staged_files}
8
-
9
- - name: lint
10
- run: bun lint
11
-
12
- - name: types
13
- run: bunx --bun tsc --noEmit
14
-
15
- commit-msg:
16
- jobs:
17
- - name: commitlint
18
- # using bun to run commitlint as fast as possible 🌸
19
- run: bunx --bun commitlint --edit {1}
20
-
21
- pre-push:
22
- jobs:
23
- - name: test-placeholder
24
- run: echo "ready to push! tests will go here in the future ✨"
1
+ pre-commit:
2
+ # running all checks at the same time for maximum speed ⚡
3
+ parallel: true
4
+ jobs:
5
+ - name: format
6
+ glob: '*.{ts,js,json,yml,yaml,md}'
7
+ run: bun fmt && git add .
8
+
9
+ - name: lint
10
+ run: bun lint
11
+
12
+ - name: types
13
+ run: bunx --bun tsc --noEmit
14
+
15
+ commit-msg:
16
+ jobs:
17
+ - name: commitlint
18
+ # using bun to run commitlint as fast as possible 🌸
19
+ run: bunx --bun commitlint --edit {1}
20
+
21
+ pre-push:
22
+ jobs:
23
+ - name: test-placeholder
24
+ run: echo "ready to push! tests will go here in the future ✨"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathievzm/lumi",
3
- "version": "1.1.2",
3
+ "version": "1.1.5",
4
4
  "description": "a concurrent cli tool to resize and convert images using bun and sharp",
5
5
  "keywords": [
6
6
  "bun",
@@ -45,7 +45,7 @@
45
45
  "fmt:check": "oxfmt --check",
46
46
  "lint": "oxlint",
47
47
  "lint:fix": "oxlint --fix",
48
- "postinstall": "lefthook install",
48
+ "prepare": "bunx lefthook install",
49
49
  "release": "bumpp --execute \"bun changelog\" --all",
50
50
  "start": "bun src/index.ts"
51
51
  },
@@ -56,12 +56,14 @@
56
56
  "image-extensions": "^1.1.0",
57
57
  "p-limit": "^7.3.0",
58
58
  "picocolors": "^1.1.1",
59
- "sharp": "^0.34.5"
59
+ "sharp": "^0.34.5",
60
+ "update-notifier": "^7.3.1"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@commitlint/cli": "^20.5.3",
63
64
  "@commitlint/config-conventional": "^20.5.3",
64
65
  "@types/bun": "latest",
66
+ "@types/update-notifier": "^6.0.8",
65
67
  "bumpp": "^11.1.0",
66
68
  "conventional-changelog-cli": "^5.0.0",
67
69
  "lefthook": "^2.1.6",
package/src/index.ts CHANGED
@@ -9,12 +9,19 @@ import { Temporal } from '@js-temporal/polyfill'
9
9
  import boxen from 'boxen'
10
10
  import pLimit from 'p-limit'
11
11
  import color from 'picocolors'
12
+ import updateNotifier from 'update-notifier'
12
13
 
13
14
  import { cli } from '@/args'
14
- import { ensureOutputExists, getInputPath, getOutputPath } from '@/folder'
15
+ import { getMessage } from '@/error'
16
+ import { getInput, getOutput, prepare } from '@/folder'
15
17
  import { getExtensions, getImages, getWidthAndHeight, resize } from '@/image'
16
18
 
17
- import pkg from '../package.json'
19
+ import pkg from '../package.json' with { type: 'json' }
20
+
21
+ const notifier = updateNotifier({ pkg })
22
+ notifier.notify({
23
+ boxenOptions: { borderColor: 'magenta', borderStyle: 'round', padding: 1 }
24
+ })
18
25
 
19
26
  console.clear()
20
27
 
@@ -30,9 +37,21 @@ console.log(banner, '\n')
30
37
 
31
38
  intro(color.magenta(`welcome to lumi v${pkg.version} 🩷`))
32
39
 
33
- const input = getInputPath(cli.input)
40
+ const input = getInput(cli.input)
41
+ const output = getOutput(cli.output)
42
+ await prepare(output)
43
+
44
+ let allFiles: string[] = []
45
+
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
+ }
34
54
 
35
- const allFiles = await readdir(input, { recursive: cli.recursive })
36
55
  const images = getImages(allFiles)
37
56
 
38
57
  if (images.length === 0) {
@@ -43,10 +62,18 @@ if (images.length === 0) {
43
62
 
44
63
  note(`found ${color.magenta(images.length)} images to process! 🚀`)
45
64
 
46
- const output = getOutputPath(cli.output)
47
- await ensureOutputExists(output)
65
+ let dimensions = { height: 0, width: 0 }
48
66
 
49
- const { width, height } = await getWidthAndHeight(cli.width, cli.height)
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
+ }
75
+
76
+ const { width, height } = dimensions
50
77
 
51
78
  const extensions = await getExtensions(images, cli.format)
52
79
  const limit = pLimit({ concurrency: cli.limit || 10, rejectOnClear: true })
@@ -84,6 +111,16 @@ if (result.some(pr => pr.status === 'rejected')) {
84
111
  spin.error(
85
112
  `yikes! finished with errors. processed ${color.red(processed)}/${color.red(images.length)} images in ${color.yellow(duration)} seconds 😢`
86
113
  )
114
+
115
+ for (const pr of result) {
116
+ if (pr.status === 'fulfilled') {
117
+ continue
118
+ }
119
+
120
+ const message = getMessage(pr.reason)
121
+ log.error(message)
122
+ }
123
+
87
124
  outroMessage = 'please check your input files and try again 🛠️'
88
125
  } else {
89
126
  spin.stop(`yay! ${color.green(images.length)} images processed in ${color.green(duration)} seconds! \u26A1\uFE0F`)
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Safely extracts a human-readable error message from an unknown error.
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.
10
+ */
11
+ export const getMessage = (error: unknown) => {
12
+ if (error instanceof Error) {
13
+ return error.message
14
+ }
15
+
16
+ if (typeof error === 'string') {
17
+ return error
18
+ }
19
+
20
+ return 'an unknown error occurred'
21
+ }
package/src/lib/folder.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { exists, mkdir } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
2
+ import { resolve, sep } from 'node:path'
3
3
  import { cwd } from 'node:process'
4
4
 
5
5
  import { log } from '@clack/prompts'
@@ -8,14 +8,14 @@ import color from 'picocolors'
8
8
  /**
9
9
  * Resolves the input directory path.
10
10
  *
11
- * If a path is provided via CLI arguments, it uses that path and logs it.
12
- * Otherwise, it prompts the user to enter an input path.
11
+ * If a path is provided via CLI arguments, it resolves to that path and logs the action.
12
+ * Otherwise, it defaults to the current working directory.
13
13
  *
14
- * @param input - The input path provided via CLI, if any.
14
+ * @param input - An optional input path provided via CLI arguments.
15
15
  *
16
- * @returns A promise that resolves to the chosen input path string.
16
+ * @returns The resolved input directory path.
17
17
  */
18
- export const getInputPath = (input?: string) => {
18
+ export const getInput = (input?: string) => {
19
19
  if (input !== undefined && input !== '') {
20
20
  log.info(`input folder provided: ${color.cyan(input)} 📂`)
21
21
  return input
@@ -30,14 +30,14 @@ export const getInputPath = (input?: string) => {
30
30
  /**
31
31
  * Resolves the output directory path.
32
32
  *
33
- * If a path is provided via CLI arguments, it uses that path and logs it.
34
- * Otherwise, it prompts the user to enter an output path.
33
+ * If a path is provided via CLI arguments, it resolves to that path and logs the action.
34
+ * Otherwise, it defaults to an 'output' directory in the current working directory.
35
35
  *
36
- * @param output - The output path provided via CLI, if any.
36
+ * @param output - An optional output path provided via CLI arguments.
37
37
  *
38
- * @returns A promise that resolves to the chosen output path string.
38
+ * @returns The resolved output directory path.
39
39
  */
40
- export const getOutputPath = (output?: string) => {
40
+ export const getOutput = (output?: string) => {
41
41
  if (output !== undefined && output !== '') {
42
42
  log.info(`output folder provided: ${color.cyan(output)} 📂`)
43
43
  return output
@@ -52,14 +52,14 @@ export const getOutputPath = (output?: string) => {
52
52
  /**
53
53
  * Ensures that the specified output directory exists.
54
54
  *
55
- * If the directory does not exist, it creates it recursively.
56
- * Logs a confirmation message once the folder is ready.
55
+ * If the directory does not exist, it is created recursively.
56
+ * A confirmation message is logged once the directory is ready.
57
57
  *
58
- * @param output - The path to the output directory.
58
+ * @param output - The absolute or relative path to the output directory.
59
59
  *
60
- * @returns A promise that resolves when the directory is verified or created.
60
+ * @returns A promise that resolves when the directory is verified or successfully created.
61
61
  */
62
- export const ensureOutputExists = async (output: string) => {
62
+ export const prepare = async (output: string) => {
63
63
  const outputExists = await exists(output)
64
64
 
65
65
  if (!outputExists) {
@@ -68,3 +68,24 @@ export const ensureOutputExists = async (output: string) => {
68
68
 
69
69
  log.info(`output folder ready: ${color.cyan(output)} ✅`, { spacing: 0 })
70
70
  }
71
+
72
+ /**
73
+ * Guards against path traversal attacks by ensuring a target path is strictly within a specified base folder.
74
+ *
75
+ * @param folder - The base directory path that the target path must reside within.
76
+ * @param path - The target path to verify.
77
+ *
78
+ * @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.
80
+ */
81
+ export const guard = (folder: string, path: string) => {
82
+ const resolved = resolve(folder)
83
+ const resolvedPath = resolve(path)
84
+ const normalized = resolved.endsWith(sep) ? resolved : resolved + sep
85
+
86
+ if (!resolvedPath.startsWith(normalized)) {
87
+ throw new Error('path traversal detected 🚫')
88
+ }
89
+
90
+ return true
91
+ }
package/src/lib/image.ts CHANGED
@@ -4,56 +4,60 @@ import { type Option } from '@clack/prompts'
4
4
  import imageExtensions from 'image-extensions'
5
5
  import sharp, { type AvailableFormatInfo } from 'sharp'
6
6
 
7
+ import { guard } from './folder'
7
8
  import { askExtensions, askWidthAndHeight } from './prompt'
8
9
 
9
10
  /**
10
- * Parameters for the image resizing operation.
11
+ * Configuration parameters required for the image resizing and conversion operation.
11
12
  */
12
13
  interface ResizeParams {
13
14
  /**
14
- * The relative path of the image within the input directory.
15
+ * The relative file path of the source image within the input directory.
15
16
  */
16
17
  readonly image: string
17
18
  /**
18
- * The absolute or relative path to the input directory.
19
+ * The absolute or relative path to the source directory containing the input images.
19
20
  */
20
21
  readonly input: string
21
22
  /**
22
- * The absolute or relative path to the output directory.
23
+ * The absolute or relative path to the destination directory for the processed images.
23
24
  */
24
25
  readonly output: string
25
26
  /**
26
- * The target width for the resized image.
27
+ * The target width in pixels for the resized image.
27
28
  */
28
29
  readonly width: number
29
30
  /**
30
- * The target height for the resized image.
31
+ * The target height in pixels for the resized image.
31
32
  */
32
33
  readonly height: number
33
34
  /**
34
- * The filename (without extension) for the output file.
35
+ * The desired filename for the output file, excluding the extension.
35
36
  */
36
37
  readonly name: string
37
38
  /**
38
- * The target file extension (e.g., '.png', '.webp') for the output file.
39
+ * The target file extension (e.g., '.png', '.webp') dictating the output format.
39
40
  */
40
41
  readonly extension: string
41
42
  }
42
43
 
44
+ const inputFormats = imageExtensions.map(format => `.${format}`)
45
+ const validExtensions = new Set(inputFormats)
46
+
43
47
  /**
44
- * Type guard to check if a value is a Sharp AvailableFormatInfo object.
48
+ * A type guard to verify if an unknown value conforms to the `AvailableFormatInfo` structure from Sharp.
45
49
  *
46
- * @param value - The value to check.
50
+ * @param value - The unknown value to evaluate.
47
51
  *
48
- * @returns True if the value matches the AvailableFormatInfo structure.
52
+ * @returns `true` if the value is a valid `AvailableFormatInfo` object, otherwise `false`.
49
53
  */
50
54
  const isFormatInfo = (value: unknown): value is AvailableFormatInfo =>
51
55
  typeof value === 'object' && value !== null && 'output' in value && 'id' in value
52
56
 
53
57
  /**
54
- * Retrieves the list of image formats supported by Sharp for output.
58
+ * Retrieves a list of image formats supported by the Sharp library for output processing.
55
59
  *
56
- * @returns An array of options representing the supported formats.
60
+ * @returns An array of prompt-compatible `Option` objects representing the supported output formats.
57
61
  */
58
62
  const getSharpFormats = () => {
59
63
  const sharpFormats = Object.values(sharp.format).filter(format => isFormatInfo(format))
@@ -65,16 +69,13 @@ const getSharpFormats = () => {
65
69
  }
66
70
 
67
71
  /**
68
- * Filters a list of files to return only those with supported image extensions.
72
+ * Filters an array of file paths, returning only those with recognized image extensions.
69
73
  *
70
- * @param files - An array of file paths to filter.
74
+ * @param files - A readonly array of file paths or filenames to filter.
71
75
  *
72
- * @returns An array of file paths that are recognized as images.
76
+ * @returns An array containing only the file paths that correspond to supported image formats.
73
77
  */
74
78
  export const getImages = (files: readonly string[]) => {
75
- const inputFormats = imageExtensions.map(format => `.${format}`)
76
- const validExtensions = new Set(inputFormats)
77
-
78
79
  const images = files.filter(file => {
79
80
  const ext = extname(file)
80
81
  return validExtensions.has(ext.toLowerCase())
@@ -84,15 +85,16 @@ export const getImages = (files: readonly string[]) => {
84
85
  }
85
86
 
86
87
  /**
87
- * Resolves the target width and height for image processing.
88
+ * Resolves the target width and height for the image processing operation.
88
89
  *
89
- * If both dimensions are provided via CLI and are valid, they are used.
90
- * Otherwise, it prompts the user to enter the desired dimensions.
90
+ * If valid dimensions (greater than 0) are provided via CLI arguments, they are utilized.
91
+ * If either dimension is missing or invalid, an interactive prompt will request the dimensions from the user.
91
92
  *
92
- * @param width - The width provided via CLI.
93
- * @param height - The height provided via CLI.
93
+ * @param width - The target width parsed from CLI arguments.
94
+ * @param height - The target height parsed from CLI arguments.
94
95
  *
95
- * @returns A promise that resolves to an object containing the width and height.
96
+ * @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.
96
98
  */
97
99
  export const getWidthAndHeight = (width: number, height: number) => {
98
100
  const notWidth = isNaN(width) || width <= 0
@@ -102,49 +104,53 @@ export const getWidthAndHeight = (width: number, height: number) => {
102
104
  return askWidthAndHeight()
103
105
  }
104
106
 
107
+ if (width > 16_383 || height > 16_383) {
108
+ throw new Error('dimensions must be less than 16384 pixels 🚫')
109
+ }
110
+
105
111
  return Promise.resolve({ height, width })
106
112
  }
107
113
 
108
114
  /**
109
- * Determines the output extensions for a set of images.
115
+ * Determines the target output formats for a collection of input images.
110
116
  *
111
- * If a global format is specified, it returns that format as the default for all images.
112
- * Otherwise, it identifies the unique extensions present in the input images and prompts
113
- * the user to map each original extension to a desired output format.
117
+ * If a global format flag is provided, it dictates the default output format for all images.
118
+ * In the absence of a global format, it extracts the unique extensions from the input images
119
+ * and prompts the user to map each original extension to a specific output format interactively.
114
120
  *
115
- * @param images - An array of image filenames to analyze.
116
- * @param format - An optional global output format.
121
+ * @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.
117
123
  *
118
- * @returns A promise that resolves to a record mapping input extensions to output formats.
124
+ * @returns A promise resolving to a record that maps input extensions (or 'default') to their chosen output formats.
119
125
  */
120
126
  export const getExtensions = (images: readonly string[], format?: string) => {
121
127
  if (format !== undefined && format !== '') {
122
128
  return Promise.resolve({ default: format } as Record<string, string>)
123
129
  }
124
130
 
125
- const extensions = [
126
- ...new Set(
127
- images.flatMap(image => {
128
- const ext = extname(image)
129
- return ext ? ext.toLowerCase() : ''
130
- })
131
- )
132
- ].filter(Boolean)
131
+ const extensionsSet = new Set<string>()
132
+ for (const image of images) {
133
+ const ext = image ? extname(image) : ''
134
+ if (ext) {
135
+ extensionsSet.add(ext.toLowerCase())
136
+ }
137
+ }
138
+ const extensions = [...extensionsSet]
133
139
 
134
140
  const formats = getSharpFormats()
135
141
  return askExtensions(extensions, formats)
136
142
  }
137
143
 
138
144
  /**
139
- * Resizes an image using the Sharp library.
145
+ * Processes an image by resizing and potentially converting its format using the Sharp library.
140
146
  *
141
- * Handles both static and animated images (e.g., GIFs, WebP), maintaining
142
- * aspect ratio with a 'contain' fit and transparent background where applicable.
147
+ * This operation supports both static and animated images (e.g., GIFs, WebP). It maintains
148
+ * the original aspect ratio utilizing a 'contain' fit and applies a transparent background where necessary.
143
149
  *
144
- * @param params - The parameters for the resize operation.
150
+ * @param params - The configuration parameters dictating the resize and conversion operations.
145
151
  *
146
- * @returns A promise that resolves to a success message string.
147
- * @throws Error if the image processing fails.
152
+ * @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.
148
154
  */
149
155
  export const resize = async (params: ResizeParams) => {
150
156
  const { image, input, output, width, height, name, extension } = params
@@ -153,6 +159,8 @@ export const resize = async (params: ResizeParams) => {
153
159
  const inputPath = join(input, image)
154
160
  const outputPath = join(output, `${name}${extension}`)
155
161
 
162
+ guard(output, outputPath)
163
+
156
164
  await sharp(inputPath, { animated: true })
157
165
  .resize(width, height, { background: 'transparent', fit: 'contain' })
158
166
  .toFile(outputPath)
package/src/lib/prompt.ts CHANGED
@@ -4,11 +4,56 @@ import { type Option, cancel, group, select, text } from '@clack/prompts'
4
4
  import color from 'picocolors'
5
5
 
6
6
  /**
7
- * Prompts the user for the target width and height of the images.
7
+ * Validates the quantity of numeric matches extracted from the user's dimension input.
8
8
  *
9
- * Validates that both inputs are positive numbers.
9
+ * Ensures the user provides at least one and at most two valid numeric strings.
10
10
  *
11
- * @returns A promise that resolves to an object containing the numeric width and height.
11
+ * @param matches - A readonly array of numeric strings extracted via regex, or `null`/`undefined` if no matches were
12
+ * found.
13
+ *
14
+ * @returns An error message string if the validation fails, or `undefined` if the matches are valid.
15
+ */
16
+ const validateMatches = (matches: readonly string[] | null | undefined) => {
17
+ if (!matches || matches.length === 0) {
18
+ return 'please enter at least one valid positive number 🚫'
19
+ }
20
+
21
+ if (matches.length > 2) {
22
+ return 'please enter at most two numbers (width and height) 🚫'
23
+ }
24
+
25
+ return undefined
26
+ }
27
+
28
+ /**
29
+ * Verifies that the parsed dimensions fall within the acceptable boundaries for image processing.
30
+ *
31
+ * Enforces that both dimensions are strictly positive and do not exceed Sharp's maximum pixel limit.
32
+ *
33
+ * @param width - The target width in pixels.
34
+ * @param height - The target height in pixels.
35
+ *
36
+ * @returns An error message string detailing the failure reason, or `undefined` if the dimensions are valid.
37
+ */
38
+ const validateLimits = (width: number, height: number) => {
39
+ if (width <= 0 || height <= 0) {
40
+ return 'dimensions must be greater than zero 🚫'
41
+ }
42
+
43
+ if (width > 16_383 || height > 16_383) {
44
+ return 'dimensions must be less than 16_384 pixels 🚫'
45
+ }
46
+
47
+ return undefined
48
+ }
49
+
50
+ /**
51
+ * Interactively prompts the user to provide the target width and height for the processed images.
52
+ *
53
+ * Parses the input to support either a single value (for square dimensions) or two values
54
+ * (for specific width and height). If the user cancels the prompt, the process exits.
55
+ *
56
+ * @returns A promise resolving to an object containing the validated, numeric `width` and `height`.
12
57
  */
13
58
  export const askWidthAndHeight = async () => {
14
59
  const regex = /\d+/gv
@@ -18,23 +63,18 @@ export const askWidthAndHeight = async () => {
18
63
  placeholder: 'e.g. "1080" (square) or "1920 1080" (width x height)',
19
64
  validate: value => {
20
65
  const matches = value?.match(regex)
66
+ const matchError = validateMatches(matches)
21
67
 
22
- if (!matches || matches.length === 0) {
23
- return 'please enter at least one valid positive number 🚫'
24
- }
25
-
26
- if (matches.length > 2) {
27
- return 'please enter at most two numbers (width and height) 🚫'
68
+ // If the regex parsing failed or gave bad counts, return the error early 🛡️
69
+ if (matchError !== undefined || !matches) {
70
+ return matchError
28
71
  }
29
72
 
30
73
  const width = Number(matches[0])
31
- const height = matches.length === 2 ? Number(matches[1]) : width
74
+ const height = matches[1] === undefined ? width : Number(matches[1])
32
75
 
33
- if (width <= 0 || height <= 0) {
34
- return 'dimensions must be greater than zero 🚫'
35
- }
36
-
37
- return undefined
76
+ // Delegate the final math check to our helper ✨
77
+ return validateLimits(width, height)
38
78
  }
39
79
  })
40
80
 
@@ -46,21 +86,21 @@ export const askWidthAndHeight = async () => {
46
86
  const matches = result.match(regex) ?? []
47
87
 
48
88
  const width = Number(matches[0])
49
- const height = matches.length === 2 ? Number(matches[1]) : width
89
+ const height = matches[1] === undefined ? width : Number(matches[1])
50
90
 
51
91
  return { height, width }
52
92
  }
53
93
 
54
94
  /**
55
- * Prompts the user to select an output format for each unique file extension found.
95
+ * Interactively prompts the user to select a desired output format for each unique input file extension.
56
96
  *
57
- * Dynamically generates a group of selection prompts based on the provided extensions.
58
- * If the user cancels any selection, the process exits.
97
+ * Dynamically constructs a series of selection prompts based on the provided list of extensions.
98
+ * If the user cancels any of the prompts, the process exits.
59
99
  *
60
- * @param extensions - An array of unique file extensions found in the input.
61
- * @param formats - An array of available output formats supported by the system.
100
+ * @param extensions - A readonly array of unique file extensions detected in the input images.
101
+ * @param formats - A readonly array of output format options supported by the Sharp library.
62
102
  *
63
- * @returns A promise that resolves to a record mapping each original extension to its chosen output format.
103
+ * @returns A promise resolving to a record that maps each original file extension to its selected output format string.
64
104
  */
65
105
  export const askExtensions = (extensions: readonly string[], formats: readonly Readonly<Option<string>>[]) => {
66
106
  const promptGroups: Record<string, () => Promise<string | symbol>> = {}
@@ -70,11 +110,15 @@ export const askExtensions = (extensions: readonly string[], formats: readonly R
70
110
  continue
71
111
  }
72
112
 
73
- promptGroups[extension] = () =>
74
- select({
113
+ promptGroups[extension] = () => {
114
+ const initialValue = formats.some(format => format.value === extension) ? extension : undefined
115
+
116
+ return select({
117
+ initialValue,
75
118
  message: `what ${color.magenta('format')} do you want to use for ${color.cyan(extension)} files? 🎨`,
76
119
  options: [...formats]
77
120
  })
121
+ }
78
122
  }
79
123
 
80
124
  return group(promptGroups, {
package/tsconfig.json CHANGED
@@ -1,40 +1,40 @@
1
- {
2
- "compilerOptions": {
3
- // Environment setup & latest features
4
- "lib": ["ESNext"],
5
- "target": "ESNext",
6
- "module": "Preserve",
7
- "moduleDetection": "force",
8
- "jsx": "react-jsx",
9
- "allowJs": true,
10
- "types": ["bun"],
11
-
12
- // Bundler mode
13
- "moduleResolution": "bundler",
14
- "allowImportingTsExtensions": true,
15
- "verbatimModuleSyntax": true,
16
- "noEmit": true,
17
-
18
- // Best practices
19
- "strict": true,
20
- "skipLibCheck": true,
21
- "noFallthroughCasesInSwitch": true,
22
- "noUncheckedIndexedAccess": true,
23
- "noImplicitOverride": true,
24
-
25
- // Some stricter flags (disabled by default)
26
- "noUnusedLocals": true,
27
- "noUnusedParameters": true,
28
- "noImplicitReturns": true,
29
- "noImplicitAny": true,
30
- "noImplicitThis": true,
31
- "noUncheckedSideEffectImports": true,
32
- "noPropertyAccessFromIndexSignature": true,
33
-
34
- // Paths
35
- "paths": {
36
- "@/*": ["./src/lib/*"]
37
- }
38
- },
39
- "include": ["**/*.ts", "**/*.d.ts"]
40
- }
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+ "types": ["bun"],
11
+
12
+ // Bundler mode
13
+ "moduleResolution": "bundler",
14
+ "allowImportingTsExtensions": true,
15
+ "verbatimModuleSyntax": true,
16
+ "noEmit": true,
17
+
18
+ // Best practices
19
+ "strict": true,
20
+ "skipLibCheck": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+ "noUncheckedIndexedAccess": true,
23
+ "noImplicitOverride": true,
24
+
25
+ // Some stricter flags (disabled by default)
26
+ "noUnusedLocals": true,
27
+ "noUnusedParameters": true,
28
+ "noImplicitReturns": true,
29
+ "noImplicitAny": true,
30
+ "noImplicitThis": true,
31
+ "noUncheckedSideEffectImports": true,
32
+ "noPropertyAccessFromIndexSignature": true,
33
+
34
+ // Paths
35
+ "paths": {
36
+ "@/*": ["./src/lib/*"]
37
+ }
38
+ },
39
+ "include": ["**/*.ts", "**/*.d.ts"]
40
+ }