@nathievzm/lumi 1.1.5 → 1.1.6
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/.env.example +12 -11
- package/.github/github.d.ts +8 -0
- package/.github/scripts/add-labels-pr.ts +44 -0
- package/.github/scripts/auto-assign.ts +8 -0
- package/.github/scripts/octokit.ts +10 -0
- package/.github/workflows/add-labels-pr.yml +20 -0
- package/.github/workflows/auto-assign.yml +13 -19
- package/.jules/bolt.md +36 -0
- package/.jules/palette.md +32 -0
- package/.jules/sentinel.md +47 -0
- package/.oxlintrc.json +2 -1
- package/.vscode/settings.json +3 -1
- package/CHANGELOG.md +45 -0
- package/README.md +2 -0
- package/lefthook.yml +0 -1
- package/package.json +5 -4
- package/src/env.d.ts +6 -6
- package/src/index.ts +76 -87
- package/src/lib/args.ts +8 -7
- package/src/lib/error.ts +60 -13
- package/src/lib/folder.ts +37 -9
- package/src/lib/image.ts +10 -7
- package/src/lib/update.ts +24 -0
- package/tsconfig.json +1 -1
- package/.Jules/bolt.md +0 -8
- package/.Jules/palette.md +0 -20
- package/.Jules/sentinel.md +0 -19
package/.env.example
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
# Default target dimensions for images
|
|
2
|
-
WIDTH = '800'
|
|
3
|
-
HEIGHT = '600'
|
|
4
|
-
|
|
5
|
-
# Default folder paths (can be relative or absolute)
|
|
6
|
-
INPUT_FOLDER = './input'
|
|
7
|
-
OUTPUT_FOLDER = './output'
|
|
8
|
-
|
|
9
|
-
# Default processing settings
|
|
10
|
-
FORMAT = '.webp'
|
|
11
|
-
LIMIT = '10'
|
|
1
|
+
# Default target dimensions for images
|
|
2
|
+
WIDTH = '800'
|
|
3
|
+
HEIGHT = '600'
|
|
4
|
+
|
|
5
|
+
# Default folder paths (can be relative or absolute)
|
|
6
|
+
INPUT_FOLDER = './input'
|
|
7
|
+
OUTPUT_FOLDER = './output'
|
|
8
|
+
|
|
9
|
+
# Default processing settings
|
|
10
|
+
FORMAT = '.webp'
|
|
11
|
+
LIMIT = '10'
|
|
12
|
+
RECURSIVE = 'false'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { exit } from 'node:process'
|
|
2
|
+
|
|
3
|
+
import { type PullRequest } from '@octokit/webhooks-types'
|
|
4
|
+
|
|
5
|
+
import { context, octokit } from './octokit'
|
|
6
|
+
|
|
7
|
+
const isPullRequest = (data: unknown): data is PullRequest =>
|
|
8
|
+
typeof data === 'object' && data !== null && 'head' in data
|
|
9
|
+
|
|
10
|
+
const pullRequest = context.payload.pull_request
|
|
11
|
+
|
|
12
|
+
if (!isPullRequest(pullRequest)) {
|
|
13
|
+
throw new Error('this event does not contain a pull request! 😢')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const branch = /\w+\/(\d+)-\w+/v.exec(pullRequest.head.ref)
|
|
17
|
+
|
|
18
|
+
if (branch === null || branch.length < 2 || branch[1] === undefined) {
|
|
19
|
+
console.log(`skipping: branch ${pullRequest.head.ref} does not match the issue format. ⏭️`)
|
|
20
|
+
exit(0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const issueNumber = Number(branch[1])
|
|
24
|
+
|
|
25
|
+
const { data: issue, status } = await octokit.rest.issues.get({
|
|
26
|
+
issue_number: issueNumber,
|
|
27
|
+
owner: context.repo.owner,
|
|
28
|
+
repo: context.repo.repo
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (status !== 200) {
|
|
32
|
+
throw new Error('failed to fetch the issue! 😟')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const labels = issue.labels
|
|
36
|
+
.map(label => (typeof label === 'string' ? label : label.name))
|
|
37
|
+
.filter((name): name is string => typeof name === 'string' && name.length > 0)
|
|
38
|
+
|
|
39
|
+
await octokit.rest.issues.addLabels({
|
|
40
|
+
issue_number: context.issue.number,
|
|
41
|
+
labels,
|
|
42
|
+
owner: context.repo.owner,
|
|
43
|
+
repo: context.repo.repo
|
|
44
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: add labels from issue to pull request
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
add-labels:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- name: check out repository code
|
|
12
|
+
uses: actions/checkout@v6
|
|
13
|
+
- name: set up bun
|
|
14
|
+
uses: oven-sh/setup-bun@v2
|
|
15
|
+
- name: install dependencies
|
|
16
|
+
run: bun install
|
|
17
|
+
- name: run bun script to add labels from issue to pull request
|
|
18
|
+
run: bun .github/scripts/add-labels-pr
|
|
19
|
+
env:
|
|
20
|
+
GITHUB_TOKEN: ${{ secrets.LUMI_ADD_LABELS_PR }}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
name: auto assign author
|
|
1
|
+
name: auto assign author to issue or pr
|
|
2
|
+
|
|
3
|
+
run-name: ${{ github.actor }} is automatically assigned to the issue or pr they just opened ✅
|
|
2
4
|
|
|
3
5
|
on:
|
|
4
6
|
issues:
|
|
@@ -9,22 +11,14 @@ on:
|
|
|
9
11
|
jobs:
|
|
10
12
|
assign:
|
|
11
13
|
runs-on: ubuntu-latest
|
|
12
|
-
permissions:
|
|
13
|
-
issues: write
|
|
14
|
-
pull-requests: write
|
|
15
14
|
steps:
|
|
16
|
-
- name:
|
|
17
|
-
uses: actions/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
owner: context.repo.owner,
|
|
27
|
-
repo: context.repo.repo,
|
|
28
|
-
issue_number: context.issue.number,
|
|
29
|
-
assignees: [author]
|
|
30
|
-
})
|
|
15
|
+
- name: check out repository code
|
|
16
|
+
uses: actions/checkout@v6
|
|
17
|
+
- name: set up bun
|
|
18
|
+
uses: oven-sh/setup-bun@v2
|
|
19
|
+
- name: install dependencies
|
|
20
|
+
run: bun install
|
|
21
|
+
- name: run bun script to auto-assign the author
|
|
22
|
+
run: bun .github/scripts/auto-assign
|
|
23
|
+
env:
|
|
24
|
+
GITHUB_TOKEN: ${{ secrets.LUMI_AUTOASSIGN }}
|
package/.jules/bolt.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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()`, `new Set()`,
|
|
4
|
+
spread operators, and `.filter()` creates unnecessary intermediate arrays and memory allocations. Furthermore,
|
|
5
|
+
repeatedly creating static Sets inside functions adds unnecessary O(N) overhead per call.
|
|
6
|
+
|
|
7
|
+
**Action:** When iterating over potentially large lists (like file paths), favor a single-pass `for...of` loop over
|
|
8
|
+
chained array methods to minimize memory footprint. Always pull static Set creation outside of function scopes to
|
|
9
|
+
initialize them once at the module level.
|
|
10
|
+
|
|
11
|
+
## 2026-05-15 - [Avoid Caching in Run-Once CLI Apps]
|
|
12
|
+
|
|
13
|
+
**Learning:** Implementing in-memory caching (like storing `sharp.format` values in a module-scoped variable) is
|
|
14
|
+
ineffective and adds unnecessary complexity in a CLI application that has a strictly 'run-once-and-exit' lifecycle. The
|
|
15
|
+
cache is built but never reused because the process terminates immediately after its primary task.
|
|
16
|
+
|
|
17
|
+
**Action:** Always consider the application's lifecycle (e.g., long-running server vs. short-lived CLI script) before
|
|
18
|
+
introducing caching or memoization. Prefer pure functions, readability, and KISS/YAGNI principles over
|
|
19
|
+
micro-optimizations that create global mutable state without a measurable benefit in the specific execution context.
|
|
20
|
+
|
|
21
|
+
## 2024-05-18 - [Replace Heavy Polyfills with Native APIs]
|
|
22
|
+
|
|
23
|
+
**Learning:** Using heavy polyfills like `@js-temporal/polyfill` solely for simple duration measurement introduces
|
|
24
|
+
significant (~90ms) startup overhead, which is detrimental to CLI performance.
|
|
25
|
+
|
|
26
|
+
**Action:** Always prefer native APIs like `performance.now()` for simple duration tracking to avoid the overhead of
|
|
27
|
+
parsing and instantiating large external libraries.
|
|
28
|
+
|
|
29
|
+
## 2024-05-19 - [Avoid Awaiting Dynamic Imports in Critical Path]
|
|
30
|
+
|
|
31
|
+
**Learning:** Using \`await import(...)\` for a heavy module during the synchronous CLI startup phase blocks execution,
|
|
32
|
+
failing to deliver the intended performance benefit of a dynamic import (it just shifts the penalty to a different
|
|
33
|
+
point).
|
|
34
|
+
|
|
35
|
+
**Action:** If deferring a heavy module for a non-critical side effect (like update checking), use promise chaining
|
|
36
|
+
(\`.then().catch()\`) without \`await\` to allow the main execution thread to continue immediately.
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
|
|
6
|
+
**Action:** Always output detailed errors for individual batch items when a batch process fails, even if it adds to
|
|
7
|
+
terminal noise, because the user needs to correct specific items.
|
|
8
|
+
|
|
9
|
+
## 2026-05-13 - Friendly CLI Errors
|
|
10
|
+
|
|
11
|
+
**Learning:** Raw stack traces from unhandled Promise rejections (like `readdir` on a non-existent directory) are
|
|
12
|
+
intimidating for CLI users.
|
|
13
|
+
|
|
14
|
+
**Action:** Always wrap file I/O operations in `try...catch` blocks and use UI logging tools (like `@clack/prompts`) to
|
|
15
|
+
provide actionable, human-readable error messages before gracefully exiting.
|
|
16
|
+
|
|
17
|
+
## 2026-05-14 - Default CLI Prompt Values
|
|
18
|
+
|
|
19
|
+
**Learning:** When prompting users for repeated or common file conversions, failing to default to the original format
|
|
20
|
+
creates unnecessary friction. Users expect smart defaults that save keystrokes.
|
|
21
|
+
|
|
22
|
+
**Action:** Always set an `initialValue` in CLI selection prompts (`@clack/prompts`) where a logical default exists,
|
|
23
|
+
such as defaulting to a file's original extension during conversion options.
|
|
24
|
+
|
|
25
|
+
## 2026-05-18 - Rejected Default Value for Dimensions
|
|
26
|
+
|
|
27
|
+
**Learning:** Adding a strict `defaultValue` (e.g., '1080') to the dimensions prompt overrides the helpful `placeholder`
|
|
28
|
+
text visually in the UI, making the interface less descriptive and potentially confusing for users who need custom
|
|
29
|
+
sizes.
|
|
30
|
+
|
|
31
|
+
**Action:** Prioritize descriptive placeholders over hardcoded default values for free-text inputs where the user might
|
|
32
|
+
legitimately need a wide variety of formats (like dimensions).
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
|
|
6
|
+
**Learning:** Tools handling image processing can easily be abused to consume excessive memory if user-provided
|
|
7
|
+
dimensions are unchecked, potentially crashing the Node.js/Bun process.
|
|
8
|
+
|
|
9
|
+
**Prevention:** Implement strict length and dimension validation boundaries for all user input governing asset
|
|
10
|
+
generation, catching errors gracefully without leaking system details.
|
|
11
|
+
|
|
12
|
+
## 2026-05-14 - Fix Path Traversal in Image Output
|
|
13
|
+
|
|
14
|
+
**Vulnerability:** A path traversal vulnerability existed in `src/lib/image.ts`. The output file path was constructed by
|
|
15
|
+
blindly concatenating user input (`cli.format` passed as `extension`) using `join`. An attacker could pass a format
|
|
16
|
+
string like `/../../../tmp/evil.png` to write files to arbitrary locations outside the intended output directory.
|
|
17
|
+
|
|
18
|
+
**Learning:** We must not blindly trust user input that influences file paths, especially file extensions or format
|
|
19
|
+
modifiers. When working with Node.js `path.join`, it resolves relative segments, which can escape the current directory.
|
|
20
|
+
|
|
21
|
+
**Prevention:** Always validate constructed file paths. Before performing any file operation, resolve the final output
|
|
22
|
+
path and check if it strictly starts with the resolved target directory path to prevent path traversals.
|
|
23
|
+
|
|
24
|
+
## 2026-05-18 - [Path Traversal in Input Resolution]
|
|
25
|
+
|
|
26
|
+
**Vulnerability:** The application was vulnerable to path traversal because it resolved the input image path via
|
|
27
|
+
`join(input, image)` and passed it directly to `sharp` without validation.
|
|
28
|
+
|
|
29
|
+
**Learning:** Even though the `outputPath` was properly guarded against path traversal, the missing guard on `inputPath`
|
|
30
|
+
could allow an attacker to read arbitrary files off the filesystem by specifying a malicious `image` filename with `../`
|
|
31
|
+
sequences.
|
|
32
|
+
|
|
33
|
+
**Prevention:** Always sanitize and guard both input and output paths to ensure they strictly reside within the intended
|
|
34
|
+
boundaries.
|
|
35
|
+
|
|
36
|
+
## 2026-05-19 - [Null Byte Injection in Path Resolution]
|
|
37
|
+
|
|
38
|
+
**Vulnerability:** The `guard` function used to prevent path traversal was relying on `node:path`'s `resolve` and
|
|
39
|
+
`startsWith` to verify paths. However, strings containing null bytes (`\0`) could cause `resolve` to behave unexpectedly
|
|
40
|
+
or terminate strings prematurely in underlying C++ extensions or certain filesystem API scenarios, effectively bypassing
|
|
41
|
+
the `startsWith` validation check.
|
|
42
|
+
|
|
43
|
+
**Learning:** Node's built-in path module does not inherently protect against null byte injection, and combining strings
|
|
44
|
+
with null bytes can lead to bypassing directory containment checks.
|
|
45
|
+
|
|
46
|
+
**Prevention:** Always explicitly check for and reject null bytes (`\0`) in user-supplied paths before using them in
|
|
47
|
+
file system operations or path resolutions.
|
package/.oxlintrc.json
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"no-continue": "off",
|
|
25
25
|
"require-param-type": "off",
|
|
26
26
|
"require-returns-type": "off",
|
|
27
|
-
"max-dependencies": ["warn", { "max": 16 }]
|
|
27
|
+
"max-dependencies": ["warn", { "max": 16 }],
|
|
28
|
+
"max-classes-per-file": ["warn", { "max": 3 }]
|
|
28
29
|
},
|
|
29
30
|
"env": {
|
|
30
31
|
"builtin": true
|
package/.vscode/settings.json
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
"oxc.enable.oxlint": true,
|
|
6
6
|
"oxc.enable.oxfmt": true,
|
|
7
7
|
"editor.codeActionsOnSave": {
|
|
8
|
-
"source.fixAll.oxc": "always"
|
|
8
|
+
"source.fixAll.oxc": "always",
|
|
9
|
+
"source.format.oxc": "always",
|
|
10
|
+
"source.fixAll": "always"
|
|
9
11
|
},
|
|
10
12
|
"files.insertFinalNewline": false,
|
|
11
13
|
"files.trimTrailingWhitespace": true
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
## [1.1.6](https://github.com/nathievzm/lumi/compare/v1.1.5...v1.1.6) (2026-05-19)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
- prevent path traversal on input image resolution
|
|
6
|
+
([cfe9f2a](https://github.com/nathievzm/lumi/commit/cfe9f2a78d570b63fbfe2051bc21b13710930c0e))
|
|
7
|
+
- **prompt:** remove defaultValue to prefer descriptive placeholder
|
|
8
|
+
([bf63182](https://github.com/nathievzm/lumi/commit/bf631822bf0ab3c9bbc99600692ca7143d651bb6))
|
|
9
|
+
- remove step that logs the whole github json
|
|
10
|
+
([1ff461d](https://github.com/nathievzm/lumi/commit/1ff461d9192421c6562d85436e45d30fd0b22085))
|
|
11
|
+
- revert changes from jules
|
|
12
|
+
([2a4db48](https://github.com/nathievzm/lumi/commit/2a4db4843b381da26fb42b1c11839f469029e78e))
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- add code to add the issue labels to the pr
|
|
17
|
+
([d8b5d87](https://github.com/nathievzm/lumi/commit/d8b5d8789879f76a9969a67223593db89638a3ac))
|
|
18
|
+
- add test workflow to sync pr with issue
|
|
19
|
+
([a4f8e10](https://github.com/nathievzm/lumi/commit/a4f8e1032d12283a456003d5d274bef98914028f))
|
|
20
|
+
- add ts script to get pull request
|
|
21
|
+
([c7f0e2d](https://github.com/nathievzm/lumi/commit/c7f0e2d9b20b0040889d6dcae76b62eaf5fb17f9))
|
|
22
|
+
- log found connected found issue
|
|
23
|
+
([edb6375](https://github.com/nathievzm/lumi/commit/edb6375a3881361afc0819b27f71418d560d62b4))
|
|
24
|
+
- move notify function to update module
|
|
25
|
+
([d701256](https://github.com/nathievzm/lumi/commit/d701256b69b54eae195090ff23dd4ed64f2b0cc1))
|
|
26
|
+
- **prompt:** add default value to dimension prompt
|
|
27
|
+
([7271ae5](https://github.com/nathievzm/lumi/commit/7271ae5aaef008a8024b916deaae6d6465b9c7bc))
|
|
28
|
+
- remove error when branch doesn't have correct format
|
|
29
|
+
([03fc8fb](https://github.com/nathievzm/lumi/commit/03fc8fb50466cedd78b959b51c52cd5c52800144))
|
|
30
|
+
- replace auto-assign workflow with ts script and bun action
|
|
31
|
+
([aeda5fa](https://github.com/nathievzm/lumi/commit/aeda5fa6d09b030a50c079c0ca628ecbd1cd3d50))
|
|
32
|
+
- replace small try/catch by global try/catch block with new errors
|
|
33
|
+
([74f3d2c](https://github.com/nathievzm/lumi/commit/74f3d2cff8f95e7d521670d9eefca55510ccd3e9))
|
|
34
|
+
- use gh script action to run js scripts
|
|
35
|
+
([3afe134](https://github.com/nathievzm/lumi/commit/3afe134d76c5d7d14c7cd1dbdce384e0507d5f48))
|
|
36
|
+
|
|
37
|
+
### Performance Improvements
|
|
38
|
+
|
|
39
|
+
- cache sharp formats to avoid redundant computation
|
|
40
|
+
([3eea037](https://github.com/nathievzm/lumi/commit/3eea037679b3b9677a13fa4898c2bb81ff31b0c9))
|
|
41
|
+
- cache sharp formats to avoid redundant computation and fix CI
|
|
42
|
+
([4eeea88](https://github.com/nathievzm/lumi/commit/4eeea88ce1daebafcf8417e996dde515aa6fbf86))
|
|
43
|
+
- replace @js-temporal/polyfill with performance.now()
|
|
44
|
+
([e28a380](https://github.com/nathievzm/lumi/commit/e28a38065d9f5bc08ea31d04fa37d7e171f42d29))
|
|
45
|
+
|
|
1
46
|
## [1.1.5](https://github.com/nathievzm/lumi/compare/v1.1.4...v1.1.5) (2026-05-14)
|
|
2
47
|
|
|
3
48
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ A fast, interactive CLI tool for batch image processing. Resize, convert, and op
|
|
|
14
14
|
- **Animated Support:** Seamlessly handles animated GIFs and WebP files.
|
|
15
15
|
- **Concurrency Control:** Fine-tune performance with configurable processing limits.
|
|
16
16
|
- **Environment Driven:** Fully configurable via `.env` files or CLI flags.
|
|
17
|
+
- **Update Notifications:** Automatically alerts you when a new version is available.
|
|
17
18
|
|
|
18
19
|
## 📦 Installation
|
|
19
20
|
|
|
@@ -111,6 +112,7 @@ bun install
|
|
|
111
112
|
- `bun fmt:check`: Check for formatting issues.
|
|
112
113
|
- `bun changelog`: Update the changelog.
|
|
113
114
|
- `bun release`: Release a new version with `bumpp` and update the changelog.
|
|
115
|
+
- `bun prepare`: Install git hooks with `lefthook`.
|
|
114
116
|
|
|
115
117
|
## 📄 License
|
|
116
118
|
|
package/lefthook.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathievzm/lumi",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "a concurrent cli tool to resize and convert images using bun and sharp",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -50,8 +50,7 @@
|
|
|
50
50
|
"start": "bun src/index.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@clack/prompts": "^1.
|
|
54
|
-
"@js-temporal/polyfill": "^0.5.1",
|
|
53
|
+
"@clack/prompts": "^1.4.0",
|
|
55
54
|
"boxen": "^8.0.1",
|
|
56
55
|
"image-extensions": "^1.1.0",
|
|
57
56
|
"p-limit": "^7.3.0",
|
|
@@ -60,15 +59,17 @@
|
|
|
60
59
|
"update-notifier": "^7.3.1"
|
|
61
60
|
},
|
|
62
61
|
"devDependencies": {
|
|
62
|
+
"@actions/github": "^9.1.1",
|
|
63
63
|
"@commitlint/cli": "^20.5.3",
|
|
64
64
|
"@commitlint/config-conventional": "^20.5.3",
|
|
65
|
+
"@octokit/webhooks-types": "^7.6.1",
|
|
65
66
|
"@types/bun": "latest",
|
|
66
67
|
"@types/update-notifier": "^6.0.8",
|
|
67
68
|
"bumpp": "^11.1.0",
|
|
68
69
|
"conventional-changelog-cli": "^5.0.0",
|
|
69
70
|
"lefthook": "^2.1.6",
|
|
70
71
|
"oxfmt": "^0.47.0",
|
|
71
|
-
"oxlint": "^1.
|
|
72
|
+
"oxlint": "^1.65.0",
|
|
72
73
|
"oxlint-tsgolint": "^0.22.1",
|
|
73
74
|
"typescript": "^6.0.3"
|
|
74
75
|
}
|
package/src/env.d.ts
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
declare module 'bun' {
|
|
2
2
|
interface Env {
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* The default target width in pixels for resized images.
|
|
5
5
|
*/
|
|
6
6
|
WIDTH?: string
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* The default target height in pixels for resized images.
|
|
9
9
|
*/
|
|
10
10
|
HEIGHT?: string
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* The default path to the input directory containing source images.
|
|
13
13
|
*/
|
|
14
14
|
INPUT_FOLDER?: string
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* The default path to the destination directory where processed images will be saved.
|
|
17
17
|
*/
|
|
18
18
|
OUTPUT_FOLDER?: string
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* The default global output format extension (e.g., `.webp`, `.png`).
|
|
21
21
|
*/
|
|
22
22
|
FORMAT?: string
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
24
|
+
* The default concurrent processing limit.
|
|
25
25
|
*/
|
|
26
26
|
LIMIT?: string
|
|
27
27
|
/**
|
package/src/index.ts
CHANGED
|
@@ -1,130 +1,119 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { readdir } from 'node:fs/promises'
|
|
4
3
|
import { basename, extname } from 'node:path'
|
|
5
4
|
import { exit } from 'node:process'
|
|
6
5
|
|
|
7
6
|
import { intro, log, note, outro, spinner } from '@clack/prompts'
|
|
8
|
-
import { Temporal } from '@js-temporal/polyfill'
|
|
9
7
|
import boxen from 'boxen'
|
|
10
8
|
import pLimit from 'p-limit'
|
|
11
9
|
import color from 'picocolors'
|
|
12
|
-
import updateNotifier from 'update-notifier'
|
|
13
10
|
|
|
14
11
|
import { cli } from '@/args'
|
|
15
|
-
import {
|
|
16
|
-
import { getInput, getOutput, prepare } from '@/folder'
|
|
12
|
+
import { FolderError, ImageError, LumiError } from '@/error'
|
|
13
|
+
import { getInput, getOutput, prepare, readFiles } from '@/folder'
|
|
17
14
|
import { getExtensions, getImages, getWidthAndHeight, resize } from '@/image'
|
|
15
|
+
import { notifyUpdate } from '@/update'
|
|
18
16
|
|
|
19
17
|
import pkg from '../package.json' with { type: 'json' }
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
boxenOptions: { borderColor: 'magenta', borderStyle: 'round', padding: 1 }
|
|
24
|
-
})
|
|
19
|
+
try {
|
|
20
|
+
void notifyUpdate(pkg)
|
|
25
21
|
|
|
26
|
-
console.clear()
|
|
22
|
+
console.clear()
|
|
27
23
|
|
|
28
|
-
const banner = boxen('lumi', {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
24
|
+
const banner = boxen('lumi', {
|
|
25
|
+
backgroundColor: 'magenta',
|
|
26
|
+
borderColor: 'magenta',
|
|
27
|
+
borderStyle: 'round',
|
|
28
|
+
padding: { bottom: 2, left: 15, right: 15, top: 2 },
|
|
29
|
+
textAlignment: 'center'
|
|
30
|
+
})
|
|
35
31
|
|
|
36
|
-
console.log(banner, '\n')
|
|
32
|
+
console.log(banner, '\n')
|
|
37
33
|
|
|
38
|
-
intro(color.magenta(`welcome to lumi v${pkg.version} 🩷`))
|
|
34
|
+
intro(color.magenta(`welcome to lumi v${pkg.version} 🩷`))
|
|
39
35
|
|
|
40
|
-
const input = getInput(cli.input)
|
|
41
|
-
const output = getOutput(cli.output)
|
|
42
|
-
await prepare(output)
|
|
36
|
+
const input = getInput(cli.input)
|
|
37
|
+
const output = getOutput(cli.output)
|
|
38
|
+
await prepare(output)
|
|
43
39
|
|
|
44
|
-
|
|
40
|
+
const allFiles = await readFiles(input, cli.recursive)
|
|
41
|
+
const images = getImages(allFiles)
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
const message = getMessage(error)
|
|
50
|
-
log.error(`yikes! couldn't read the input folder: ${message} 😢`)
|
|
51
|
-
outro('please check your input path and try again 👋')
|
|
52
|
-
exit(1)
|
|
53
|
-
}
|
|
43
|
+
if (images.length === 0) {
|
|
44
|
+
throw new ImageError('no valid images found in the input folder 😭')
|
|
45
|
+
}
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
note(`found ${color.magenta(images.length)} images to process! 🚀`)
|
|
56
48
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
outro('please add some images to the input folder and try again 👋')
|
|
60
|
-
exit(1)
|
|
61
|
-
}
|
|
49
|
+
const { width, height } = await getWidthAndHeight(cli.width, cli.height)
|
|
50
|
+
const extensions = await getExtensions(images, cli.format)
|
|
62
51
|
|
|
63
|
-
|
|
52
|
+
const limit = pLimit({ concurrency: cli.limit || 10, rejectOnClear: true })
|
|
64
53
|
|
|
65
|
-
|
|
54
|
+
const spin = spinner()
|
|
55
|
+
spin.start(`processing: ${color.magenta(0)}/${color.magenta(images.length)} images 🔃`)
|
|
66
56
|
|
|
67
|
-
|
|
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
|
-
}
|
|
57
|
+
let processed = 0
|
|
75
58
|
|
|
76
|
-
const
|
|
59
|
+
const startTime = performance.now()
|
|
77
60
|
|
|
78
|
-
const
|
|
79
|
-
|
|
61
|
+
const promises = images.map(image =>
|
|
62
|
+
limit(async () => {
|
|
63
|
+
const ext = extname(image)
|
|
64
|
+
const name = basename(image, ext)
|
|
65
|
+
const extension = extensions['default'] ?? extensions[ext] ?? '.png'
|
|
80
66
|
|
|
81
|
-
|
|
82
|
-
spin.start(`processing: ${color.magenta(0)}/${color.magenta(images.length)} images 🔃`)
|
|
67
|
+
await resize({ extension, height, image, input, name, output, width })
|
|
83
68
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const promises = images.map(image =>
|
|
89
|
-
limit(async () => {
|
|
90
|
-
const ext = extname(image)
|
|
91
|
-
const name = basename(image, ext)
|
|
92
|
-
const extension = extensions['default'] ?? extensions[ext] ?? '.png'
|
|
93
|
-
|
|
94
|
-
await resize({ extension, height, image, input, name, output, width })
|
|
69
|
+
processed++
|
|
70
|
+
spin.message(`processing: ${color.green(processed)}/${color.magenta(images.length)} images 🔃`)
|
|
71
|
+
})
|
|
72
|
+
)
|
|
95
73
|
|
|
96
|
-
|
|
97
|
-
spin.message(`processing: ${color.green(processed)}/${color.magenta(images.length)} images 🔃`)
|
|
98
|
-
})
|
|
99
|
-
)
|
|
74
|
+
log.message()
|
|
100
75
|
|
|
101
|
-
|
|
76
|
+
const result = await Promise.allSettled(promises)
|
|
102
77
|
|
|
103
|
-
const
|
|
78
|
+
const endTime = performance.now()
|
|
79
|
+
const duration = ((endTime - startTime) / 1000).toFixed(2)
|
|
104
80
|
|
|
105
|
-
|
|
106
|
-
const duration = startTime.until(endTime).total('seconds').toFixed(2)
|
|
81
|
+
let outroMessage = ''
|
|
107
82
|
|
|
108
|
-
|
|
83
|
+
if (result.some(pr => pr.status === 'rejected')) {
|
|
84
|
+
spin.error(
|
|
85
|
+
`yikes! finished with errors. processed ${color.red(processed)}/${color.red(images.length)} images in ${color.yellow(duration)} seconds 😢`
|
|
86
|
+
)
|
|
109
87
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
88
|
+
for (const pr of result) {
|
|
89
|
+
if (pr.status === 'fulfilled') {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
114
92
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
continue
|
|
93
|
+
const message = LumiError.getMessage(pr.reason)
|
|
94
|
+
log.error(color.red(message))
|
|
118
95
|
}
|
|
119
96
|
|
|
120
|
-
|
|
121
|
-
|
|
97
|
+
outroMessage = 'please check your input files and try again 🛠️'
|
|
98
|
+
} else {
|
|
99
|
+
spin.stop(
|
|
100
|
+
`yay! ${color.green(images.length)} images processed in ${color.green(duration)} seconds! \u26A1\uFE0F`
|
|
101
|
+
)
|
|
102
|
+
outroMessage = 'bye 👋'
|
|
122
103
|
}
|
|
123
104
|
|
|
124
|
-
outroMessage
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
105
|
+
outro(color.magenta(outroMessage))
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = LumiError.getMessage(error)
|
|
108
|
+
|
|
109
|
+
if (error instanceof FolderError) {
|
|
110
|
+
log.error(color.red(`folder issue: ${message}`))
|
|
111
|
+
} else if (error instanceof ImageError) {
|
|
112
|
+
log.error(color.red(`image issue: ${message}`))
|
|
113
|
+
} else {
|
|
114
|
+
log.error(color.red(`unexpected anomaly: ${message} 👽`))
|
|
115
|
+
}
|
|
129
116
|
|
|
130
|
-
outro(color.magenta(
|
|
117
|
+
outro(color.magenta('please check your configuration and try again 👋'))
|
|
118
|
+
exit(1)
|
|
119
|
+
}
|
package/src/lib/args.ts
CHANGED
|
@@ -5,7 +5,7 @@ const { values } = parseArgs({
|
|
|
5
5
|
args: Bun.argv.slice(2),
|
|
6
6
|
options: {
|
|
7
7
|
/**
|
|
8
|
-
* Global output format. Defaults to `FORMAT`
|
|
8
|
+
* Global output format. Defaults to the `FORMAT` environment variable.
|
|
9
9
|
*/
|
|
10
10
|
format: {
|
|
11
11
|
default: Bun.env.FORMAT,
|
|
@@ -13,7 +13,7 @@ const { values } = parseArgs({
|
|
|
13
13
|
type: 'string'
|
|
14
14
|
},
|
|
15
15
|
/**
|
|
16
|
-
* Target height. Defaults to `HEIGHT`
|
|
16
|
+
* Target height. Defaults to the `HEIGHT` environment variable.
|
|
17
17
|
*/
|
|
18
18
|
height: {
|
|
19
19
|
default: Bun.env.HEIGHT,
|
|
@@ -21,7 +21,7 @@ const { values } = parseArgs({
|
|
|
21
21
|
type: 'string'
|
|
22
22
|
},
|
|
23
23
|
/**
|
|
24
|
-
* Input directory path. Defaults to `INPUT_FOLDER`
|
|
24
|
+
* Input directory path. Defaults to the `INPUT_FOLDER` environment variable.
|
|
25
25
|
*/
|
|
26
26
|
input: {
|
|
27
27
|
default: Bun.env.INPUT_FOLDER,
|
|
@@ -29,7 +29,7 @@ const { values } = parseArgs({
|
|
|
29
29
|
type: 'string'
|
|
30
30
|
},
|
|
31
31
|
/**
|
|
32
|
-
* Concurrent processing limit. Defaults to `LIMIT`
|
|
32
|
+
* Concurrent processing limit. Defaults to the `LIMIT` environment variable.
|
|
33
33
|
*/
|
|
34
34
|
limit: {
|
|
35
35
|
default: Bun.env.LIMIT,
|
|
@@ -37,7 +37,7 @@ const { values } = parseArgs({
|
|
|
37
37
|
type: 'string'
|
|
38
38
|
},
|
|
39
39
|
/**
|
|
40
|
-
* Output directory path. Defaults to `OUTPUT_FOLDER`
|
|
40
|
+
* Output directory path. Defaults to the `OUTPUT_FOLDER` environment variable.
|
|
41
41
|
*/
|
|
42
42
|
output: {
|
|
43
43
|
default: Bun.env.OUTPUT_FOLDER,
|
|
@@ -45,7 +45,7 @@ const { values } = parseArgs({
|
|
|
45
45
|
type: 'string'
|
|
46
46
|
},
|
|
47
47
|
/**
|
|
48
|
-
* Whether to process the input directory recursively. Defaults to `RECURSIVE`
|
|
48
|
+
* Whether to process the input directory recursively. Defaults to the `RECURSIVE` environment variable.
|
|
49
49
|
*/
|
|
50
50
|
recursive: {
|
|
51
51
|
default: Boolean(Bun.env.RECURSIVE),
|
|
@@ -60,7 +60,7 @@ const { values } = parseArgs({
|
|
|
60
60
|
type: 'string'
|
|
61
61
|
},
|
|
62
62
|
/**
|
|
63
|
-
* Target width. Defaults to `WIDTH`
|
|
63
|
+
* Target width. Defaults to the `WIDTH` environment variable.
|
|
64
64
|
*/
|
|
65
65
|
width: {
|
|
66
66
|
default: Bun.env.WIDTH,
|
|
@@ -83,6 +83,7 @@ const { input, output, format, recursive } = values
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* The consolidated CLI configuration object.
|
|
86
|
+
*
|
|
86
87
|
* Contains resolved paths, dimensions, and processing limits.
|
|
87
88
|
*/
|
|
88
89
|
export const cli = { format, height, input, limit, output, recursive, width }
|
package/src/lib/error.ts
CHANGED
|
@@ -1,21 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Base error class for all Lumi-specific errors.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ensuring a consistent string output for logging or user feedback.
|
|
6
|
-
*
|
|
7
|
-
* @param error - The caught error to process.
|
|
8
|
-
*
|
|
9
|
-
* @returns The error message string, or a fallback message if the type is unrecognized.
|
|
4
|
+
* Extends the built-in `Error` class and allows for an optional underlying cause.
|
|
10
5
|
*/
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
export class LumiError extends Error {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a new `LumiError` instance.
|
|
9
|
+
*
|
|
10
|
+
* @param message - The error message.
|
|
11
|
+
* @param error - The underlying error or cause.
|
|
12
|
+
*/
|
|
13
|
+
constructor(message: string, error?: unknown) {
|
|
14
|
+
super(message, { cause: error })
|
|
15
|
+
this.name = 'LumiError'
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Extracts a human-readable message from an unknown error object.
|
|
20
|
+
*
|
|
21
|
+
* @param error - The error to extract the message from.
|
|
22
|
+
*
|
|
23
|
+
* @returns The error message, or a generic string if the type is unknown.
|
|
24
|
+
*/
|
|
25
|
+
static getMessage(error: unknown) {
|
|
26
|
+
if (error instanceof Error) {
|
|
27
|
+
return error.message
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof error === 'string') {
|
|
31
|
+
return error
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return 'an unknown error occurred'
|
|
18
35
|
}
|
|
36
|
+
}
|
|
19
37
|
|
|
20
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Error thrown when an issue occurs with folder operations.
|
|
40
|
+
*/
|
|
41
|
+
export class FolderError extends LumiError {
|
|
42
|
+
/**
|
|
43
|
+
* Creates a new `FolderError` instance.
|
|
44
|
+
*
|
|
45
|
+
* @param message - The error message.
|
|
46
|
+
* @param error - The underlying error or cause.
|
|
47
|
+
*/
|
|
48
|
+
constructor(message: string, error?: unknown) {
|
|
49
|
+
super(message, { cause: error })
|
|
50
|
+
this.name = 'FolderError'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Error thrown when an issue occurs with image processing operations.
|
|
56
|
+
*/
|
|
57
|
+
export class ImageError extends LumiError {
|
|
58
|
+
/**
|
|
59
|
+
* Creates a new `ImageError` instance.
|
|
60
|
+
*
|
|
61
|
+
* @param message - The error message.
|
|
62
|
+
* @param error - The underlying error or cause.
|
|
63
|
+
*/
|
|
64
|
+
constructor(message: string, error?: unknown) {
|
|
65
|
+
super(message, { cause: error })
|
|
66
|
+
this.name = 'ImageError'
|
|
67
|
+
}
|
|
21
68
|
}
|
package/src/lib/folder.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { exists, mkdir } from 'node:fs/promises'
|
|
1
|
+
import { exists, mkdir, readdir } from 'node:fs/promises'
|
|
2
2
|
import { resolve, sep } from 'node:path'
|
|
3
3
|
import { cwd } from 'node:process'
|
|
4
4
|
|
|
5
5
|
import { log } from '@clack/prompts'
|
|
6
6
|
import color from 'picocolors'
|
|
7
7
|
|
|
8
|
+
import { FolderError } from './error'
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Resolves the input directory path.
|
|
10
12
|
*
|
|
11
13
|
* If a path is provided via CLI arguments, it resolves to that path and logs the action.
|
|
12
14
|
* Otherwise, it defaults to the current working directory.
|
|
13
15
|
*
|
|
14
|
-
* @param input - An
|
|
16
|
+
* @param input - An input path provided via CLI arguments.
|
|
15
17
|
*
|
|
16
18
|
* @returns The resolved input directory path.
|
|
17
19
|
*/
|
|
@@ -33,7 +35,7 @@ export const getInput = (input?: string) => {
|
|
|
33
35
|
* If a path is provided via CLI arguments, it resolves to that path and logs the action.
|
|
34
36
|
* Otherwise, it defaults to an 'output' directory in the current working directory.
|
|
35
37
|
*
|
|
36
|
-
* @param output - An
|
|
38
|
+
* @param output - An output path provided via CLI arguments.
|
|
37
39
|
*
|
|
38
40
|
* @returns The resolved output directory path.
|
|
39
41
|
*/
|
|
@@ -60,13 +62,34 @@ export const getOutput = (output?: string) => {
|
|
|
60
62
|
* @returns A promise that resolves when the directory is verified or successfully created.
|
|
61
63
|
*/
|
|
62
64
|
export const prepare = async (output: string) => {
|
|
63
|
-
|
|
65
|
+
try {
|
|
66
|
+
const outputExists = await exists(output)
|
|
67
|
+
|
|
68
|
+
if (!outputExists) {
|
|
69
|
+
await mkdir(output, { recursive: true })
|
|
70
|
+
}
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
log.info(`output folder ready: ${color.cyan(output)} ✨`, { spacing: 0 })
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new FolderError(`could not prepare the output folder: ${output}`, error)
|
|
67
75
|
}
|
|
76
|
+
}
|
|
68
77
|
|
|
69
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Reads the contents of a directory.
|
|
80
|
+
*
|
|
81
|
+
* @param input - The path to the directory to read.
|
|
82
|
+
* @param recursive - Whether to read the directory recursively. Defaults to `false`.
|
|
83
|
+
*
|
|
84
|
+
* @returns A promise that resolves to an array of file names or relative paths.
|
|
85
|
+
* @throws { FolderError } If the directory cannot be read.
|
|
86
|
+
*/
|
|
87
|
+
export const readFiles = async (input: string, recursive = false) => {
|
|
88
|
+
try {
|
|
89
|
+
return await readdir(input, { recursive })
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new FolderError(`could not read the input folder: ${input}`, error)
|
|
92
|
+
}
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
/**
|
|
@@ -76,15 +99,20 @@ export const prepare = async (output: string) => {
|
|
|
76
99
|
* @param path - The target path to verify.
|
|
77
100
|
*
|
|
78
101
|
* @returns `true` if the target path is securely contained within the base folder.
|
|
79
|
-
* @throws {
|
|
102
|
+
* @throws { FolderError } If the target path resolves outside the base folder, indicating a potential path traversal
|
|
103
|
+
* attempt.
|
|
80
104
|
*/
|
|
81
105
|
export const guard = (folder: string, path: string) => {
|
|
106
|
+
if (path.includes('\0')) {
|
|
107
|
+
throw new FolderError('path traversal detected 🚫')
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
const resolved = resolve(folder)
|
|
83
111
|
const resolvedPath = resolve(path)
|
|
84
112
|
const normalized = resolved.endsWith(sep) ? resolved : resolved + sep
|
|
85
113
|
|
|
86
114
|
if (!resolvedPath.startsWith(normalized)) {
|
|
87
|
-
throw new
|
|
115
|
+
throw new FolderError('path traversal detected 🚫')
|
|
88
116
|
}
|
|
89
117
|
|
|
90
118
|
return true
|
package/src/lib/image.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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 { ImageError } from './error'
|
|
7
8
|
import { guard } from './folder'
|
|
8
9
|
import { askExtensions, askWidthAndHeight } from './prompt'
|
|
9
10
|
|
|
@@ -59,7 +60,7 @@ const isFormatInfo = (value: unknown): value is AvailableFormatInfo =>
|
|
|
59
60
|
*
|
|
60
61
|
* @returns An array of prompt-compatible `Option` objects representing the supported output formats.
|
|
61
62
|
*/
|
|
62
|
-
const getSharpFormats = () => {
|
|
63
|
+
const getSharpFormats = (): Option<string>[] => {
|
|
63
64
|
const sharpFormats = Object.values(sharp.format).filter(format => isFormatInfo(format))
|
|
64
65
|
const formats: Option<string>[] = sharpFormats
|
|
65
66
|
.filter(format => format.output.file)
|
|
@@ -94,7 +95,7 @@ export const getImages = (files: readonly string[]) => {
|
|
|
94
95
|
* @param height - The target height parsed from CLI arguments.
|
|
95
96
|
*
|
|
96
97
|
* @returns A promise resolving to an object containing the validated `width` and `height`.
|
|
97
|
-
* @throws {
|
|
98
|
+
* @throws { ImageError } If the provided or prompted dimensions exceed Sharp's 16383 pixel limit.
|
|
98
99
|
*/
|
|
99
100
|
export const getWidthAndHeight = (width: number, height: number) => {
|
|
100
101
|
const notWidth = isNaN(width) || width <= 0
|
|
@@ -105,7 +106,7 @@ export const getWidthAndHeight = (width: number, height: number) => {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
if (width > 16_383 || height > 16_383) {
|
|
108
|
-
throw new
|
|
109
|
+
throw new ImageError('dimensions must be less than 16384 pixels 🚫')
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
return Promise.resolve({ height, width })
|
|
@@ -119,7 +120,7 @@ export const getWidthAndHeight = (width: number, height: number) => {
|
|
|
119
120
|
* and prompts the user to map each original extension to a specific output format interactively.
|
|
120
121
|
*
|
|
121
122
|
* @param images - A readonly array of input image filenames to analyze for unique extensions.
|
|
122
|
-
* @param format -
|
|
123
|
+
* @param format - A global output format string provided via CLI arguments.
|
|
123
124
|
*
|
|
124
125
|
* @returns A promise resolving to a record that maps input extensions (or 'default') to their chosen output formats.
|
|
125
126
|
*/
|
|
@@ -150,7 +151,8 @@ export const getExtensions = (images: readonly string[], format?: string) => {
|
|
|
150
151
|
* @param params - The configuration parameters dictating the resize and conversion operations.
|
|
151
152
|
*
|
|
152
153
|
* @returns A promise that resolves to a descriptive success message upon completion.
|
|
153
|
-
* @throws {
|
|
154
|
+
* @throws { ImageError } If the image processing fails or if a path traversal attempt is detected during output
|
|
155
|
+
* resolution.
|
|
154
156
|
*/
|
|
155
157
|
export const resize = async (params: ResizeParams) => {
|
|
156
158
|
const { image, input, output, width, height, name, extension } = params
|
|
@@ -159,6 +161,7 @@ export const resize = async (params: ResizeParams) => {
|
|
|
159
161
|
const inputPath = join(input, image)
|
|
160
162
|
const outputPath = join(output, `${name}${extension}`)
|
|
161
163
|
|
|
164
|
+
guard(input, inputPath)
|
|
162
165
|
guard(output, outputPath)
|
|
163
166
|
|
|
164
167
|
await sharp(inputPath, { animated: true })
|
|
@@ -166,7 +169,7 @@ export const resize = async (params: ResizeParams) => {
|
|
|
166
169
|
.toFile(outputPath)
|
|
167
170
|
|
|
168
171
|
return `processed and saved: ${name}${extension} `
|
|
169
|
-
} catch (error:
|
|
170
|
-
throw new
|
|
172
|
+
} catch (error: unknown) {
|
|
173
|
+
throw new ImageError(`error processing ${image} `, error)
|
|
171
174
|
}
|
|
172
175
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Package } from 'update-notifier'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Asynchronously checks for package updates and displays a notification if an update is available.
|
|
5
|
+
*
|
|
6
|
+
* @param pkg - The package information, typically imported from package.json, used to check for updates.
|
|
7
|
+
*
|
|
8
|
+
* @returns A boolean indicating whether the update check and notification process executed without throwing an error.
|
|
9
|
+
*/
|
|
10
|
+
export const notifyUpdate = async (pkg: Readonly<Package>) => {
|
|
11
|
+
try {
|
|
12
|
+
const { default: updateNotifier } = await import('update-notifier')
|
|
13
|
+
|
|
14
|
+
const notifier = updateNotifier({ pkg })
|
|
15
|
+
|
|
16
|
+
notifier.notify({
|
|
17
|
+
boxenOptions: { borderColor: 'magenta', borderStyle: 'round', padding: 1 }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
package/tsconfig.json
CHANGED
package/.Jules/bolt.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
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.
|
package/.Jules/palette.md
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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.
|
package/.Jules/sentinel.md
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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.
|