@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 +26 -0
- package/.jules/palette.md +26 -0
- package/.jules/sentinel.md +23 -0
- package/CHANGELOG.md +36 -0
- package/lefthook.yml +0 -3
- package/package.json +1 -1
- package/src/index.ts +3 -2
- package/src/lib/args.ts +1 -1
- package/src/lib/folder.ts +10 -3
- package/src/lib/image.ts +73 -14
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.
|
package/.jules/sentinel.md
CHANGED
|
@@ -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
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
|
-
|
|
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:
|
|
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
|
|
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 = ()
|
|
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
|
-
*
|
|
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
|
|
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
|
|
107
|
+
* @returns `true` if the file is a processable image, otherwise `false`.
|
|
78
108
|
*/
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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)
|