@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 +8 -0
- package/.Jules/palette.md +20 -0
- package/.Jules/sentinel.md +19 -0
- package/.commitlintrc.json +3 -3
- package/.github/workflows/auto-assign.yml +30 -30
- package/.oxlintrc.json +1 -1
- package/.vscode/settings.json +12 -12
- package/CHANGELOG.md +32 -0
- package/lefthook.yml +24 -24
- package/package.json +5 -3
- package/src/index.ts +44 -7
- package/src/lib/error.ts +21 -0
- package/src/lib/folder.ts +37 -16
- package/src/lib/image.ts +54 -46
- package/src/lib/prompt.ts +68 -24
- package/tsconfig.json +40 -40
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.
|
package/.commitlintrc.json
CHANGED
|
@@ -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
package/.vscode/settings.json
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
"
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
47
|
-
await ensureOutputExists(output)
|
|
65
|
+
let dimensions = { height: 0, width: 0 }
|
|
48
66
|
|
|
49
|
-
|
|
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`)
|
package/src/lib/error.ts
ADDED
|
@@ -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
|
|
12
|
-
* Otherwise, it
|
|
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 -
|
|
14
|
+
* @param input - An optional input path provided via CLI arguments.
|
|
15
15
|
*
|
|
16
|
-
* @returns
|
|
16
|
+
* @returns The resolved input directory path.
|
|
17
17
|
*/
|
|
18
|
-
export const
|
|
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
|
|
34
|
-
* Otherwise, it
|
|
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 -
|
|
36
|
+
* @param output - An optional output path provided via CLI arguments.
|
|
37
37
|
*
|
|
38
|
-
* @returns
|
|
38
|
+
* @returns The resolved output directory path.
|
|
39
39
|
*/
|
|
40
|
-
export const
|
|
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
|
|
56
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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')
|
|
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
|
-
*
|
|
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
|
|
50
|
+
* @param value - The unknown value to evaluate.
|
|
47
51
|
*
|
|
48
|
-
* @returns
|
|
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
|
|
58
|
+
* Retrieves a list of image formats supported by the Sharp library for output processing.
|
|
55
59
|
*
|
|
56
|
-
* @returns An array of
|
|
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
|
|
72
|
+
* Filters an array of file paths, returning only those with recognized image extensions.
|
|
69
73
|
*
|
|
70
|
-
* @param files -
|
|
74
|
+
* @param files - A readonly array of file paths or filenames to filter.
|
|
71
75
|
*
|
|
72
|
-
* @returns An array
|
|
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
|
|
90
|
-
*
|
|
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
|
|
93
|
-
* @param height - The height
|
|
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
|
|
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
|
|
115
|
+
* Determines the target output formats for a collection of input images.
|
|
110
116
|
*
|
|
111
|
-
* If a global format is
|
|
112
|
-
*
|
|
113
|
-
* the user to map each original extension to a
|
|
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 -
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
]
|
|
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
|
-
*
|
|
145
|
+
* Processes an image by resizing and potentially converting its format using the Sharp library.
|
|
140
146
|
*
|
|
141
|
-
*
|
|
142
|
-
* aspect ratio
|
|
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
|
|
150
|
+
* @param params - The configuration parameters dictating the resize and conversion operations.
|
|
145
151
|
*
|
|
146
|
-
* @returns A promise that resolves to a success message
|
|
147
|
-
* @throws Error
|
|
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
|
-
*
|
|
7
|
+
* Validates the quantity of numeric matches extracted from the user's dimension input.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Ensures the user provides at least one and at most two valid numeric strings.
|
|
10
10
|
*
|
|
11
|
-
* @
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
74
|
+
const height = matches[1] === undefined ? width : Number(matches[1])
|
|
32
75
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
89
|
+
const height = matches[1] === undefined ? width : Number(matches[1])
|
|
50
90
|
|
|
51
91
|
return { height, width }
|
|
52
92
|
}
|
|
53
93
|
|
|
54
94
|
/**
|
|
55
|
-
*
|
|
95
|
+
* Interactively prompts the user to select a desired output format for each unique input file extension.
|
|
56
96
|
*
|
|
57
|
-
* Dynamically
|
|
58
|
-
* If the user cancels any
|
|
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 -
|
|
61
|
-
* @param formats -
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|