@nathievzm/lumi 1.1.6 → 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/.jules/bolt.md CHANGED
@@ -34,3 +34,29 @@ point).
34
34
 
35
35
  **Action:** If deferring a heavy module for a non-critical side effect (like update checking), use promise chaining
36
36
  (\`.then().catch()\`) without \`await\` to allow the main execution thread to continue immediately.
37
+
38
+ ## 2025-02-14 - [Dynamic Import of Heavy Modules in CLI]
39
+
40
+ **Learning:** `sharp` is a heavyweight module that blocks execution for >130ms on import. This severely impacts the
41
+ startup time of the CLI, making `lumi --help` and error reporting feel slow.
42
+
43
+ **Action:** Used dynamic imports (`await import('sharp')`) to lazily load the library only when image processing methods
44
+ (`resize` and `getSharpFormats`) are called. This avoids importing `sharp` during the synchronous CLI startup path.
45
+
46
+ ## 2024-05-18 - Avoid typeof on imported default types
47
+
48
+ **Learning:** When asserting a type on a dynamically imported module by extracting its default export (e.g.
49
+ `typeof sharp`), the `typeof` keyword must not be applied if the module has been imported specifically into the type
50
+ space via `import { type default as sharp } from 'sharp'`. Applying `typeof` to a type-only import results in a `TS2693`
51
+ compilation error because `typeof` operates on values, not types.
52
+
53
+ **Action:** When strictly importing types to satisfy `import(consistent-type-specifier-style)`, directly apply the
54
+ imported type without `typeof`.
55
+
56
+ ## 2025-05-25 - [Optimize getImages path resolution]
57
+
58
+ **Learning:** `resolve` is significantly slower than `join` in Node's path module, particularly within tight loops.
59
+ Repeatedly resolving a known directory prefix causes measurable overhead when processing large volumes of files.
60
+
61
+ **Action:** Whenever iterating over many files that share a base directory, `resolve` the base directory once outside
62
+ the loop and use `join(resolvedBase, file)` inside the loop to drastically improve performance.
package/.jules/palette.md CHANGED
@@ -30,3 +30,29 @@ sizes.
30
30
 
31
31
  **Action:** Prioritize descriptive placeholders over hardcoded default values for free-text inputs where the user might
32
32
  legitimately need a wide variety of formats (like dimensions).
33
+
34
+ ## 2026-05-20 - Rejected Initial Value for Dimensions
35
+
36
+ **Learning:** Using `initialValue` for dimensions also overrides the `placeholder` text visually in the UI just like
37
+ `defaultValue` does, making the interface less descriptive since users can't see that they can provide two values.
38
+
39
+ **Action:** Avoid using `initialValue` or `defaultValue` in free-text inputs like dimensions, where a helpful
40
+ placeholder explaining multiple formats is more valuable.
41
+
42
+ ## 2025-02-23 - Use placeholder instead of initialValue for CLI text prompts with descriptive help
43
+
44
+ **Learning:** In CLI text prompts (like @clack/prompts), `initialValue` fills the text field, which hides any
45
+ descriptive `placeholder` text that would otherwise provide helpful instructions to the user. Using no `initialValue`
46
+ (or `defaultValue`) ensures the `placeholder` remains visible, guiding the user on the expected input format without
47
+ them needing to clear the field first.
48
+
49
+ **Action:** Avoid setting an `initialValue` in CLI text prompts when a descriptive `placeholder` is present, to ensure
50
+ the helpful placeholder text remains visible to the user.
51
+
52
+ ## 2026-05-25 - Actionable Empty State Errors
53
+
54
+ **Learning:** When users run the tool on a directory that has images in subfolders but not in the root, an error simply
55
+ stating "no valid images found" is confusing and unhelpful.
56
+
57
+ **Action:** Always provide actionable hints in empty states or error messages, such as suggesting the `--recursive`
58
+ flag, to guide the user toward a solution.
@@ -45,3 +45,26 @@ with null bytes can lead to bypassing directory containment checks.
45
45
 
46
46
  **Prevention:** Always explicitly check for and reject null bytes (`\0`) in user-supplied paths before using them in
47
47
  file system operations or path resolutions.
48
+
49
+ ## 2026-05-20 - [Null Byte Injection in Folder Parameter]
50
+
51
+ **Vulnerability:** While the `guard` function previously checked for null bytes (`\0`) in the `path` parameter, it
52
+ neglected to perform the same check on the `folder` parameter. This inconsistency left a potential path traversal bypass
53
+ vector open if the base folder path itself originated from untrusted input.
54
+
55
+ **Learning:** Null byte injection protections must be consistently applied to all path components involved in a security
56
+ boundary check, not just the immediately obvious file path.
57
+
58
+ **Prevention:** Ensure comprehensive null byte validation (`\0`) on all path inputs (both base directories and target
59
+ paths) prior to resolving them with `node:path` functions.
60
+
61
+ ## 2025-05-21 - Early Path Component Validation
62
+
63
+ **Vulnerability:** Path traversal and null byte injection vectors existed because inputs to `node:path` functions were
64
+ not sanitised early enough.
65
+
66
+ **Learning:** By passing an unsanitised component into `node:path` functions like `join`, a null byte could prematurely
67
+ terminate strings and bypass directory boundary checks.
68
+
69
+ **Prevention:** Explicitly reject null bytes ('\0') in all user-provided path components before passing them to
70
+ `node:path` functions or combining them.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,39 @@
1
+ # [1.2.0](https://github.com/nathievzm/lumi/compare/v1.1.6...v1.2.0) (2026-05-29)
2
+
3
+ ### Bug Fixes
4
+
5
+ - guard function and recursive flag
6
+ ([41fcb3e](https://github.com/nathievzm/lumi/commit/41fcb3e38c7c271971543b2cc38665f7c545c9c2))
7
+
8
+ ### Features
9
+
10
+ - **image:** cache sharp dynamic import promise to improve performance
11
+ ([08a02fd](https://github.com/nathievzm/lumi/commit/08a02fd0a166dfd3587b76c9635fee323ddff38c))
12
+ - **ui:** add initialValue to dimensions prompt to reduce user friction
13
+ ([b72ccaf](https://github.com/nathievzm/lumi/commit/b72ccafe8dfbd66d35a5b3abbe727500f36e5921))
14
+ - **ux:** add initialValue to askWidthAndHeight prompt
15
+ ([027ec53](https://github.com/nathievzm/lumi/commit/027ec536da421716121f306fefa5d23fa5c49696))
16
+ - **ux:** add initialValue to askWidthAndHeight prompt
17
+ ([a095643](https://github.com/nathievzm/lumi/commit/a09564304ba0b49d3e9d2d45ed64a9460c578a97))
18
+ - **ux:** update journal learning about initialValue vs placeholder
19
+ ([80c873f](https://github.com/nathievzm/lumi/commit/80c873fb16ded0ad9b098670f92f9839cf0c66d7))
20
+ - **ux:** update journal learning about initialValue vs placeholder
21
+ ([3f75765](https://github.com/nathievzm/lumi/commit/3f7576541d7792a0004e8ba2dddb8d8873106303))
22
+ - **ux:** update journal learning about initialValue vs placeholder
23
+ ([b56fd87](https://github.com/nathievzm/lumi/commit/b56fd876ee00478efe24b604c5084e8c27d49509))
24
+
25
+ ### Performance Improvements
26
+
27
+ - **image:** lazily load heavy sharp module to improve startup time
28
+ ([5c3c9a4](https://github.com/nathievzm/lumi/commit/5c3c9a424e21d46c7830feb5e80517a4e274ba91))
29
+ - optimize path resolution in `getImages`
30
+ ([1b91635](https://github.com/nathievzm/lumi/commit/1b91635306851cd1840e1a11b9eacd361741993d))
31
+
32
+ ### Reverts
33
+
34
+ - Remove initialValue from dimension prompt to show placeholder
35
+ ([06b9a0c](https://github.com/nathievzm/lumi/commit/06b9a0c35089d89d056fecc8ff50e7a31b34a789))
36
+
1
37
  ## [1.1.6](https://github.com/nathievzm/lumi/compare/v1.1.5...v1.1.6) (2026-05-19)
2
38
 
3
39
  ### Bug Fixes
package/lefthook.yml CHANGED
@@ -1,6 +1,4 @@
1
1
  pre-commit:
2
- # running all checks at the same time for maximum speed ⚡
3
- parallel: true
4
2
  jobs:
5
3
  - name: format
6
4
  run: bun fmt && git add .
@@ -14,7 +12,6 @@ pre-commit:
14
12
  commit-msg:
15
13
  jobs:
16
14
  - name: commitlint
17
- # using bun to run commitlint as fast as possible 🌸
18
15
  run: bunx --bun commitlint --edit {1}
19
16
 
20
17
  pre-push:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathievzm/lumi",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
4
4
  "description": "a concurrent cli tool to resize and convert images using bun and sharp",
5
5
  "keywords": [
6
6
  "bun",
package/src/index.ts CHANGED
@@ -38,10 +38,11 @@ try {
38
38
  await prepare(output)
39
39
 
40
40
  const allFiles = await readFiles(input, cli.recursive)
41
- const images = getImages(allFiles)
41
+ const images = getImages(allFiles, input, output)
42
42
 
43
43
  if (images.length === 0) {
44
- throw new ImageError('no valid images found in the input folder 😭')
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}`)
45
46
  }
46
47
 
47
48
  note(`found ${color.magenta(images.length)} images to process! 🚀`)
package/src/lib/args.ts CHANGED
@@ -48,7 +48,7 @@ const { values } = parseArgs({
48
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
  },
package/src/lib/folder.ts CHANGED
@@ -19,6 +19,7 @@ import { FolderError } from './error'
19
19
  */
20
20
  export const getInput = (input?: string) => {
21
21
  if (input !== undefined && input !== '') {
22
+ guard(cwd(), input)
22
23
  log.info(`input folder provided: ${color.cyan(input)} 📂`)
23
24
  return input
24
25
  }
@@ -41,6 +42,7 @@ export const getInput = (input?: string) => {
41
42
  */
42
43
  export const getOutput = (output?: string) => {
43
44
  if (output !== undefined && output !== '') {
45
+ guard(cwd(), output)
44
46
  log.info(`output folder provided: ${color.cyan(output)} 📂`)
45
47
  return output
46
48
  }
@@ -103,16 +105,21 @@ export const readFiles = async (input: string, recursive = false) => {
103
105
  * attempt.
104
106
  */
105
107
  export const guard = (folder: string, path: string) => {
106
- if (path.includes('\0')) {
107
- throw new FolderError('path traversal detected 🚫')
108
+ if (path.includes('\0') || folder.includes('\0')) {
109
+ throw new FolderError('path traversal detected 🚨')
108
110
  }
109
111
 
110
112
  const resolved = resolve(folder)
111
113
  const resolvedPath = resolve(path)
114
+
115
+ if (resolved === resolvedPath) {
116
+ return true
117
+ }
118
+
112
119
  const normalized = resolved.endsWith(sep) ? resolved : resolved + sep
113
120
 
114
121
  if (!resolvedPath.startsWith(normalized)) {
115
- throw new FolderError('path traversal detected 🚫')
122
+ throw new FolderError('path traversal detected 🚨')
116
123
  }
117
124
 
118
125
  return true
package/src/lib/image.ts CHANGED
@@ -1,8 +1,8 @@
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
7
  import { ImageError } from './error'
8
8
  import { guard } from './folder'
@@ -42,9 +42,32 @@ interface ResizeParams {
42
42
  readonly extension: string
43
43
  }
44
44
 
45
+ /**
46
+ * Array of supported input image format extensions prefixed with a dot (e.g., '.jpg', '.png').
47
+ */
45
48
  const inputFormats = imageExtensions.map(format => `.${format}`)
49
+
50
+ /**
51
+ * Set of valid input image extensions for optimized lookup.
52
+ */
46
53
  const validExtensions = new Set(inputFormats)
47
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
+
48
71
  /**
49
72
  * A type guard to verify if an unknown value conforms to the `AvailableFormatInfo` structure from Sharp.
50
73
  *
@@ -60,8 +83,10 @@ const isFormatInfo = (value: unknown): value is AvailableFormatInfo =>
60
83
  *
61
84
  * @returns An array of prompt-compatible `Option` objects representing the supported output formats.
62
85
  */
63
- const getSharpFormats = (): Option<string>[] => {
86
+ const getSharpFormats = async () => {
87
+ const sharp = await getSharp()
64
88
  const sharpFormats = Object.values(sharp.format).filter(format => isFormatInfo(format))
89
+
65
90
  const formats: Option<string>[] = sharpFormats
66
91
  .filter(format => format.output.file)
67
92
  .map(format => ({ label: format.id, value: `.${format.id}` }))
@@ -70,17 +95,48 @@ const getSharpFormats = (): Option<string>[] => {
70
95
  }
71
96
 
72
97
  /**
73
- * 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.
74
102
  *
75
- * @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.
76
106
  *
77
- * @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`.
78
108
  */
79
- export const getImages = (files: readonly string[]) => {
80
- const images = files.filter(file => {
81
- const ext = extname(file)
82
- return validExtensions.has(ext.toLowerCase())
83
- })
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
+ }
84
140
 
85
141
  return images
86
142
  }
@@ -124,12 +180,13 @@ export const getWidthAndHeight = (width: number, height: number) => {
124
180
  *
125
181
  * @returns A promise resolving to a record that maps input extensions (or 'default') to their chosen output formats.
126
182
  */
127
- export const getExtensions = (images: readonly string[], format?: string) => {
183
+ export const getExtensions = async (images: readonly string[], format?: string) => {
128
184
  if (format !== undefined && format !== '') {
129
- return Promise.resolve({ default: format } as Record<string, string>)
185
+ return { default: format } as Record<string, string>
130
186
  }
131
187
 
132
188
  const extensionsSet = new Set<string>()
189
+
133
190
  for (const image of images) {
134
191
  const ext = image ? extname(image) : ''
135
192
  if (ext) {
@@ -138,7 +195,7 @@ export const getExtensions = (images: readonly string[], format?: string) => {
138
195
  }
139
196
  const extensions = [...extensionsSet]
140
197
 
141
- const formats = getSharpFormats()
198
+ const formats = await getSharpFormats()
142
199
  return askExtensions(extensions, formats)
143
200
  }
144
201
 
@@ -164,6 +221,8 @@ export const resize = async (params: ResizeParams) => {
164
221
  guard(input, inputPath)
165
222
  guard(output, outputPath)
166
223
 
224
+ const sharp = await getSharp()
225
+
167
226
  await sharp(inputPath, { animated: true })
168
227
  .resize(width, height, { background: 'transparent', fit: 'contain' })
169
228
  .toFile(outputPath)