diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..764928c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,66 @@
+name: Bug Report
+description: Create a bug report
+labels: ['bug']
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Before opening a new issue:
+
+ - Do a search of existing issues.
+ - Pull the latest updates from this repository's `dev` branch.
+
+ - type: textarea
+ attributes:
+ label: To Reproduce
+ description: A step-by-step description of how to reproduce the issue, or a link to the reproducible repository.
+ placeholder: |
+ 1. Start the application in development (npm run dev -- -f ./assets/suns.jpg -u)
+ 2. Press enter
+ 3. An error appears: "[ERROR] [CLOUD-IMAGE-UPLOAD] [UPLOAD] ENOENT: no such file or directory, open 'C:\lab\image-transformer\app\assets\suns.jpg'"
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Current vs. Expected behavior
+ description: A clear and concise description of what the bug is, and what you expected to happen.
+ placeholder: 'Following the steps from the previous section, I expected A to happen, but I observed B instead'
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Provide environment information
+ description: Please run `npm run info` in the root directory of your project and paste the results.
+ render: bash
+ placeholder: |
+ Node version: v24.11.0
+ Platform: win32
+ Arch: x64
+ V8 version: 13.6.233.10-node.28
+ npm version: 11.6.1
+ validations:
+ required: true
+
+ - type: dropdown
+ attributes:
+ label: Which area(s) are affected? (Select all that apply)
+ multiple: true
+ options:
+ - 'Not sure'
+ - 'CLI/NPM scripts usage'
+ - 'Cloudinary credentials'
+ - 'Image input/output'
+ - 'Docker'
+ - 'Others'
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Additional context
+ description: |
+ Any extra information that might help us investigate.
+ placeholder: |
+ I tested my reproduction against different `cloudinary` releases, and the first one that introduced the bug was "v2.9.0", since reverting to "v2.10.0" works.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..5aa9ac8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,36 @@
+name: Feature Request
+description: Suggest a new feature or improvement to the project
+labels: ['enhancement']
+body:
+ - type: textarea
+ attributes:
+ label: What problem will this feature address?
+ description: A clear and concise description of what the problem is.
+ placeholder: |
+ I'm always frustrated when I can't do X
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Describe the solution you'd like
+ description: A clear and concise description of what you want to happen.
+ placeholder: Add X to the core
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Describe alternatives you've considered
+ description: A clear and concise description of any alternative solutions or features you've considered.
+ placeholder: |
+ Maybe use Y as a workaround?
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Additional context
+ description: Add any other context or screenshots about the feature request here.
+ validations:
+ required: false
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..b14b135
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,24 @@
+## Summary
+
+
+## Related Issues
+
+
+## Type of Change
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Breaking change
+- [ ] Refactor
+- [ ] Documentation
+- [ ] Other (please describe):
+
+## Checklist
+- [ ] I have tested my changes locally
+- [ ] I have linked relevant issues
+- [ ] I have added screenshots for UI changes (if applicable)
+
+## Screenshots (if applicable)
+
+
+## Additional Context
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ae43a57
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,72 @@
+name: Deploy Release
+
+# This workflow will trigger on any tag/release created from the `main` branch
+on:
+ release:
+ types: [published]
+
+jobs:
+ lint-test-app:
+ name: Lint and Test App
+ if: github.event.release.target_commitish == 'main'
+ runs-on: ubuntu-latest
+ permissions:
+ checks: write
+ pull-requests: write
+ steps:
+ - name: Checkout the repository
+ uses: actions/checkout@v6
+
+ - name: Use NodeJS v24.11.0
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24.11.0
+ registry-url: https://registry.npmjs.org/
+
+ - name: Install Dependencies
+ run: |
+ cd app
+ npm ci
+
+ - name: Lint
+ run: |
+ cd app
+ npm run lint
+
+ - name: Check types
+ run: |
+ cd app
+ npm run types:check
+
+ publish-npm:
+ name: Publish to NPM registry
+ if: github.event.release.target_commitish == 'main'
+ needs: [lint-test-app]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - name: Checkout the repository
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.event.release.tag_name }}
+
+ - name: Use NodeJS v24.11.0
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24.11.0
+ registry-url: https://registry.npmjs.org/
+
+ - name: Build distribution package
+ run: |
+ cd app
+ npm ci
+ npm run build
+
+ - name: Publish package
+ run: |
+ cp LICENSE app/
+ cp docs/README_NPM.md app/README.md
+ cd app
+ npm publish --provenance --access public
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..c997d8e
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,35 @@
+name: Run CI Tests
+
+on:
+ push:
+ branches-ignore:
+ - main
+
+jobs:
+ lint-app:
+ name: Lint App
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout the repository
+ uses: actions/checkout@v6
+
+ - name: Use NodeJS v24.11.0
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24.11.0
+ registry-url: https://registry.npmjs.org/
+
+ - name: Install Dependencies
+ run: |
+ cd app
+ npm ci
+
+ - name: Lint
+ run: |
+ cd app
+ npm run lint
+
+ - name: Check types
+ run: |
+ cd app
+ npm run types:check
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..db72c81
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,17 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "attach",
+ "name": "Attach to Docker",
+ "port": 9229,
+ "address": "localhost",
+ "localRoot": "${workspaceFolder}/app",
+ "remoteRoot": "/opt/app"
+ },
+ ]
+}
diff --git a/README.md b/README.md
index baa78c3..b8b7707 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,27 @@
-## image-cli
+## cloudinary-image
-CLI that supports Cloudinary image transformations.
+Cloudinary image optimization CLI with Node.js wrappers for select [Cloudinary Node.js APIs](https://cloudinary.com/documentation/node_image_manipulation)
+covering image transformation, optimization, and asset management.
### Requirements
- Node v24+
- Docker (optional)
+- Cloudinary account
-### Usage
+## Installation
-Using Docker
+Create a `.env` file in the `/app` directory, replacing the contents of the `.env.example` file with actual values.
+
+| Variable Name | Description |
+| --- | --- |
+| CLOUDINARY_NAME | Cloudinary account name |
+| CLOUDINARY_API_KEY | Cloudinary API key |
+| CLOUDINARY_API_SECRET | Cloudinary API secret |
+
+## Usage
+
+#### A. Using Docker
1. Build the image.
`docker compose build`
@@ -17,5 +29,221 @@ Using Docker
2. Run the container.
`docker compose up`
+3. Run the [Available Scripts](#available-scripts) using Docker.
+
+4. See the examples under the [Code Samples](#-code-samples) section for more information.
+
+**Example using the development Docker image**
+
+(PowerShell - development)
+
+```sh
+docker exec cloudinary-cli-dev npm run docker:debug -- -f /opt/app/assets/sunset.jpg -u -d
+```
+
+**Example using stand-alone production Docker image**
+
+(PowerShell - production)
+
+Build the production image with
+`docker compose -f docker-compose.prod.yml build`
+
+```sh
+ docker run --rm --env-file .env `
+ -v ${pwd}/assets:/images `
+ weaponsforge/cloudinary-cli `
+ -f /images/sunset.jpg -u
+```
+
+#### B. Using Node.js
+
+1. Install dependencies.
+
+ ```sh
+ cd app
+ npm install
+ ```
+
+2. Run the [Available Scripts](#available-scripts).
+
+3. See the examples under the [Code Samples](#-code-samples) section for more information.
+
+## Available Scripts
+
+### `npm start`
+
+Optimizes an input image using the Cloudinary image transformations.
+Downloads the optimized image to a `/processed` directory relative to the input file, or to a specified output directory.
+
+> **NOTE**: this requires transpiling TypeScript into JavaScript first via `npm run build`.
+
+**Example Usage**
+
+```sh
+npm start -- -f /path/to/file.jpg -u -d
+```
+
+**CLI Guide**
+
+```sh
+npm start -- \
+ -f /path/to/file.jpg # Full input image file path
+ -o /output/folder/path # (Optional) output folder
+ -a my-asset-folder # (Optional) Cloudinary asset folder
+ -t cars,vehicles,tech # (Optional) image tags
+ -w 600 # (Optional) width to resize the image. Default is 800
+ -u # (Optional) flag to upload the input image to Cloudinary. Required on 1st run.
+ -d # (Optional) flag to delete the uploaded image in Cloudinary
+```
+
+> **NOTE**: This script is also accessible using `npx optimize` minus the `--` flag.
+
+### `npm run dev`
+
+Runs the `npm start` script in development mode with `tsx`.
+
+Example usage:
+`npm run dev -- -f /assets/sunset.jpg -u`
+
+### `npm run info`
+
+Logs the installed Node.js and npm version, environment platform, architecture and V8 version.
+
+### `npm run build`
+
+Builds JavaScript, `.d.ts` declaration files, and map files from the TypeScript source files in the `/src` directory to the `/dist` directory.
+
+### `npm run types:check`
+
+Runs type-checking without generating the JavaScript or declaration files from the TypeScript files in the `/src` directory.
+
+### `npm run lint`
+Lints TypeScript source codes.
+
+### `npm run lint:fix`
+Fixes lint errors in TypeScript files.
+
+### `npm run watch`
+
+Watches file changes in `.ts` files using the `tsc --watch` option.
+
+### `npm run docker:watch:win`
+
+Watches file changes in `.ts` files using the `tsc --watch` option with `dynamicPriorityPolling` in Docker containers running in Windows WSL2.
+
+## ๐งพ Code Samples
+
+### A. Optimize an Image
+
+```typescript
+import { join } from 'node:path'
+import dotenv from 'dotenv'
+import { CloudinaryImage } from '@/lib/image.js'
+
+dotenv.config()
+
+const main = async () => {
+ const filePath = join(process.cwd(), 'boat.jpg')
+
+ const image = new CloudinaryImage({
+ localFile: filePath,
+ cloudinaryAssetFolder: 'my-folder',
+ })
+
+ await image.upload('sea,travel')
+ await image.optimize(600)
+ await image.delete()
+}
+
+main()
+```
+
+### B. Apply Image Transformations
+
+```typescript
+import dotenv from 'dotenv'
+import { CloudinaryImage } from '@/lib/image.js'
+import { join } from 'node:path'
+
+dotenv.config()
+
+const main = async () => {
+ const filePath = join(process.cwd(), 'boat.jpg')
+
+ const image = new CloudinaryImage({
+ localFile: filePath,
+ cloudinaryAssetFolder: 'my-folder',
+ })
+
+ // Upload image to Cloudinary
+ await image.upload('sea,travel')
+
+ // Generate URL of resized image
+ const urlResize = await image.transformer
+ .resize(image.publicId, {
+ width: 450
+ })
+
+ // Generate URL of cropped image
+ const urlCropped = await image.transformer
+ .crop(image.publicId, {
+ width: 400,
+ height: 200,
+ crop: 'scale'
+ })
+
+ // Generate URL of image's new format
+ const urlFormat = await image.transformer
+ .format(image.publicId, 'webp')
+
+ // Generate URL of image with improved quality
+ const urlQuality = await image.transformer
+ .quality(image.publicId, 'auto')
+
+ // Download one of the generated images
+ const downloadFilePath = join(process.cwd(), image.name)
+ await image.service.fetch(urlCropped, downloadFilePath)
+}
+
+main()
+```
+
+### C. Using Classes
+
+```typescript
+import { join } from 'node:path'
+
+import { AssetManager } from '@/lib/cloudinary/manager.js'
+import { AssetService } from '@/lib/cloudinary/service.js'
+import { BaseImage } from '@/lib/cloudinary/baseimage.js'
+import { Transform } from '@/lib/cloudinary/transform.js'
+
+// Class for managing Cloudinary assets
+const _manager = new AssetManager()
+
+// Class for uploading and fetching images from Cloudinary
+const _service = new AssetService()
+
+// Class for generating Cloudinary image transformations
+const _transformer = new Transform()
+
+// Initialize a new BaseImage - no Cloudinary libraries
+const inputFile = join(process.cwd(), 'boat.jpg')
+const outputFile = join(process.cwd(), 'images', 'done', 'processed.jpg')
+
+const _image = new BaseImage({
+ localFile: inputFile,
+ cloudinaryAssetFolder: 'my-folder',
+ localDestination: outputFile, // optional
+})
+
+// Note: the CloudinaryImage class is composed of all these components
+```
+
+## References
+
+- [Cloudinary NPM Registry](https://www.npmjs.com/package/cloudinary)
+- [Cloudinary Node.js Docs](https://cloudinary.com/documentation/node_image_manipulation)
+
@weaponsforge
20260817
diff --git a/app/.dockerignore b/app/.dockerignore
new file mode 100644
index 0000000..07493cb
--- /dev/null
+++ b/app/.dockerignore
@@ -0,0 +1,29 @@
+node_modules/
+dist/
+.git
+.github
+.vscode
+.dockerignore
+Dockerfile
+*.zip
+*.rar
+*.tgz
+*.txt
+*.d.ts
+*.map
+.env*
+*.json
+*.blob
+*.md
+src/**/*.js
+
+LICENSE
+README.md
+
+!package.json
+!package-lock.json
+!tsconfig.json
+!.env.example
+
+assets/
+
diff --git a/app/.env.example b/app/.env.example
new file mode 100644
index 0000000..2660520
--- /dev/null
+++ b/app/.env.example
@@ -0,0 +1,3 @@
+CLOUDINARY_NAME=YOUR_CLOUDINARY_ACCOUNT_NAME
+CLOUDINARY_API_KEY=YOUR_CLOUDINARY_API_KEY
+CLOUDINARY_API_SECRET=YOUR_CLOUDINARY_API_SECRET
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
index a9e1a12..fd20608 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -7,5 +7,18 @@ dist/
*.txt
*.d.ts
*.map
+*.png
+*.jpg
+*.jpeg
+*.webp
.env*
src/**/*.js
+
+!.env.example
+
+assets/**
+!assets/bridge.jpg
+!assets/sunset.jpg
+
+LICENSE
+README.md
diff --git a/app/Dockerfile b/app/Dockerfile
index af0f847..ef04841 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -11,7 +11,7 @@ COPY package*.json ./
FROM base AS build
RUN npm ci
COPY --chown=node:node . ./
-RUN npm run transpile
+RUN npm run build
# Development target profile
FROM base AS development
@@ -29,4 +29,4 @@ RUN npm ci --only=production && npm cache clean --force
COPY --chown=node:node --from=build /opt/app/dist /opt/app/dist
USER node
-CMD ["sh"]
+ENTRYPOINT ["node", "/opt/app/dist/scripts/optimize/index.js"]
diff --git a/app/assets/bridge.jpg b/app/assets/bridge.jpg
new file mode 100644
index 0000000..adad8b4
Binary files /dev/null and b/app/assets/bridge.jpg differ
diff --git a/app/assets/sunset.jpg b/app/assets/sunset.jpg
new file mode 100644
index 0000000..bcd50a7
Binary files /dev/null and b/app/assets/sunset.jpg differ
diff --git a/app/package-lock.json b/app/package-lock.json
index 23bd658..effeda4 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,13 +1,20 @@
{
- "name": "app",
+ "name": "cloudinary-image",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "app",
+ "name": "cloudinary-image",
"version": "1.0.0",
"license": "MIT",
+ "dependencies": {
+ "cloudinary": "^2.10.0",
+ "dotenv": "^17.4.2"
+ },
+ "bin": {
+ "optimize": "dist/scripts/optimize/index.js"
+ },
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.2.0",
@@ -1097,6 +1104,18 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/cloudinary": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz",
+ "integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.23"
+ },
+ "engines": {
+ "node": ">=9"
+ }
+ },
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
@@ -1160,6 +1179,18 @@
"node": ">=8"
}
},
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1705,6 +1736,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
diff --git a/app/package.json b/app/package.json
index 4abe120..fec9372 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,23 +1,55 @@
{
- "name": "app",
+ "name": "cloudinary-image",
"version": "1.0.0",
- "description": "",
- "license": "MIT",
- "author": "weaponsforge",
+ "description": "Cloudinary image library wrapper and CLI for optimization and transformations",
"type": "module",
- "main": "index.js",
+ "main": "dist/index.js",
"engines": {
"node": ">=24"
},
+ "bin": {
+ "optimize": "./dist/scripts/optimize/index.js"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "!dist/demo",
+ "README.md",
+ "LICENSE"
+ ],
"scripts": {
+ "start": "node ./dist/scripts/optimize/index.js",
+ "dev": "tsx ./src/scripts/optimize",
"build": "tsc -p tsconfig.json && tsc-alias",
"types:check": "tsc -p tsconfig.json --noEmit",
"watch": "tsc -p tsconfig.json --watch",
"lint": "eslint \"src/**/*.ts\" *.mjs",
"lint:fix": "eslint \"src/**/*.ts\" *.mjs --fix",
- "docker:debug": "export IS_DOCKER=true && node --inspect=0.0.0.0:9229 --import tsx src/utils/sample.ts",
- "docker:watch:win": "tsc -p tsconfig.json --watch --watchFile dynamicPriorityPolling --watchDirectory dynamicPriorityPolling"
+ "docker:debug": "export IS_DOCKER=true && node --inspect=0.0.0.0:9229 --import tsx src/scripts/optimize/index.ts",
+ "docker:watch:win": "tsc -p tsconfig.json --watch --watchFile dynamicPriorityPolling --watchDirectory dynamicPriorityPolling",
+ "info": "tsx ./src/scripts/envinfo"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/weaponsforge/cloudinary-image.git"
},
+ "keywords": [
+ "cloudinary",
+ "image",
+ "transformations",
+ "cli"
+ ],
+ "author": "weaponsforge",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/weaponsforge/cloudinary-image/issues"
+ },
+ "homepage": "https://github.com/weaponsforge/cloudinary-image#readme",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.2.0",
@@ -27,5 +59,9 @@
"tsx": "^4.23.12",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
+ },
+ "dependencies": {
+ "cloudinary": "^2.10.0",
+ "dotenv": "^17.4.2"
}
}
diff --git a/app/src/demo/classes.ts b/app/src/demo/classes.ts
new file mode 100644
index 0000000..b534c9a
--- /dev/null
+++ b/app/src/demo/classes.ts
@@ -0,0 +1,27 @@
+import { join } from 'node:path'
+
+import { AssetManager } from '@/lib/cloudinary/manager.js'
+import { AssetService } from '@/lib/cloudinary/service.js'
+import { BaseImage } from '@/lib/cloudinary/baseimage.js'
+import { Transform } from '@/lib/cloudinary/transform.js'
+
+// Class for managing Cloudinary assets
+const _manager = new AssetManager()
+
+// Class for uploading and fetching images from Cloudinary
+const _service = new AssetService()
+
+// Class for generating Cloudinary image transformations
+const _transformer = new Transform()
+
+// Initialize a new BaseImage - no Cloudinary libraries
+const inputFile = join(process.cwd(), 'boat.jpg')
+const outputFile = join(process.cwd(), 'images', 'done', 'processed.jpg')
+
+const _image = new BaseImage({
+ localFile: inputFile,
+ cloudinaryAssetFolder: 'my-folder',
+ localDestination: outputFile, // optional
+})
+
+// Note: the CloudinaryImage class is composed of all these components
diff --git a/app/src/demo/quickstart.ts b/app/src/demo/quickstart.ts
new file mode 100644
index 0000000..1a7f740
--- /dev/null
+++ b/app/src/demo/quickstart.ts
@@ -0,0 +1,20 @@
+import dotenv from 'dotenv'
+import { CloudinaryImage } from '@/lib/image.js'
+import { join } from 'node:path'
+
+dotenv.config()
+
+const main = async () => {
+ const filePath = join(process.cwd(), 'boat.jpg')
+
+ const image = new CloudinaryImage({
+ localFile: filePath,
+ cloudinaryAssetFolder: 'my-folder',
+ })
+
+ await image.upload('sea,travel')
+ await image.optimize(600)
+ await image.delete()
+}
+
+main()
diff --git a/app/src/demo/transformations.ts b/app/src/demo/transformations.ts
new file mode 100644
index 0000000..9d715bd
--- /dev/null
+++ b/app/src/demo/transformations.ts
@@ -0,0 +1,44 @@
+import dotenv from 'dotenv'
+import { CloudinaryImage } from '@/lib/image.js'
+import { join } from 'node:path'
+
+dotenv.config()
+
+const main = async () => {
+ const filePath = join(process.cwd(), 'boat.jpg')
+
+ const image = new CloudinaryImage({
+ localFile: filePath,
+ cloudinaryAssetFolder: 'my-folder',
+ })
+
+ // await image.upload('sea,travel')
+
+ // Generate URL of resized image
+ const _urlResize = await image.transformer
+ .resize(image.publicId, {
+ width: 450,
+ })
+
+ // Generate URL of cropped image
+ const urlCropped = await image.transformer
+ .crop(image.publicId, {
+ width: 400,
+ height: 200,
+ crop: 'scale',
+ })
+
+ // Generate URL of image's new format
+ const _urlFormat = await image.transformer
+ .format(image.publicId, 'webp')
+
+ // Generate URL of image with improved quality
+ const _urlQuality = await image.transformer
+ .quality(image.publicId, 'auto')
+
+ // Download one of the generated images
+ const downloadFilePath = join(process.cwd(), image.name)
+ await image.service.fetch(urlCropped, downloadFilePath)
+}
+
+main()
diff --git a/app/src/index.ts b/app/src/index.ts
new file mode 100644
index 0000000..ffad053
--- /dev/null
+++ b/app/src/index.ts
@@ -0,0 +1,5 @@
+export { AssetManager } from './lib/cloudinary/manager.js'
+export { AssetService } from './lib/cloudinary/service.js'
+export { BaseImage } from './lib/cloudinary/baseimage.js'
+export { CloudinaryImage } from './lib/image.js'
+export { Transform } from './lib/cloudinary/transform.js'
diff --git a/app/src/lib/cloudinary/baseimage.ts b/app/src/lib/cloudinary/baseimage.ts
new file mode 100644
index 0000000..696aa7c
--- /dev/null
+++ b/app/src/lib/cloudinary/baseimage.ts
@@ -0,0 +1,99 @@
+import { join } from 'node:path'
+import { v2 as cloudinary } from 'cloudinary'
+
+import { createDirectory, getFileName } from '@/utils/helpers.js'
+import type { ImageLocation } from '@/types/types.js'
+
+const CLASS_NAME = 'BASE IMAGE'
+
+/**
+ * Base image class with local file definitions and metadata.
+ */
+export class BaseImage {
+ location: ImageLocation = {
+ localFile: '',
+ localDestination: '',
+ publicId: '',
+ cloudinaryAssetFolder: '',
+ }
+
+ name: string = ''
+
+ constructor (options: ImageLocation) {
+ this.initialize(options)
+
+ // Initialize Cloudinary
+ cloudinary.config({
+ cloud_name: process.env.CLOUDINARY_NAME,
+ api_key: process.env.CLOUDINARY_API_KEY,
+ api_secret: process.env.CLOUDINARY_API_SECRET,
+ })
+ }
+
+ initialize (options: ImageLocation) {
+ if (!options.localFile) {
+ throw new Error(`${CLASS_NAME}: missing localFile input`)
+ }
+
+ const fileName = getFileName(options.localFile)
+ this.name = getFileName(options.localFile)
+ this.location = options
+
+ if (!this.location.publicId) {
+ const endIndex = fileName.indexOf('.') >= 1
+ ? fileName.indexOf('.')
+ : fileName.length
+
+ this.location.publicId = fileName.substring(0, endIndex)
+ }
+
+ if (!this.location.localDestination) {
+ // Create a "processed" directory relative to the file
+ createDirectory(this.location.localFile)
+ .then(destDir => {
+ const destFile = join(destDir, fileName)
+ this.location.localDestination = destFile
+ })
+ .catch(err => {
+ throw new Error(
+ `${CLASS_NAME}: Failed to create destination directory`, {
+ cause: err,
+ })
+ })
+ } else {
+ this.location.localDestination = join(this.location.localDestination, `${fileName}`)
+ }
+ }
+
+ log (message: string) {
+ console.log(`[${this.name}]: ${message}`)
+ }
+
+ /**
+ * Sets the internal publicId
+ */
+ set publicId (publicId: string) {
+ this.location.publicId = publicId
+ }
+
+ /**
+ * Returns the internal publicId
+ */
+ get publicId () {
+ return this.location.publicId ?? '-'
+ }
+
+ /**
+ * Sets the cloudinaryAssetFolder
+ */
+ set cloudinaryAssetFolder (assetFolder: string) {
+ this.location.cloudinaryAssetFolder = assetFolder
+ }
+
+ /**
+ * Returns the internal cloudinaryAssetFolder
+ */
+ get cloudinaryAssetFolder () {
+ return this.location.cloudinaryAssetFolder ?? '-'
+ }
+}
diff --git a/app/src/lib/cloudinary/manager.ts b/app/src/lib/cloudinary/manager.ts
new file mode 100644
index 0000000..8e4aaad
--- /dev/null
+++ b/app/src/lib/cloudinary/manager.ts
@@ -0,0 +1,62 @@
+import { v2 as cloudinary, type AdminAndPublishOptions } from 'cloudinary'
+import { handleThrowError } from '@/utils/helpers.js'
+
+const METHODS = {
+ URL: 'GET URL',
+ RESOURCE: 'GET RESOURCE',
+ DELETE_RESOURCES: 'DELETE RESOURCES',
+}
+
+/**
+ * Wrapper around the Cloudinary Admin API for managing assets
+ * @see https://cloudinary.com/documentation/node_asset_administration
+ * @see https://cloudinary.com/documentation/admin_api
+ */
+export class AssetManager {
+ /**
+ * Retrieves the Cloudinary URL of an asset by `public_id`
+ * @param publicId - Cloudinary image public ID
+ * @returns Cloudinary asset URL
+ */
+ async getUrl (publicId: string) {
+ try {
+ return await cloudinary.url(publicId)
+ } catch (err) {
+ return handleThrowError(err, METHODS.URL)
+ }
+ }
+
+ /**
+ * Retrieves the metadata of a single Cloudinary resource (asset) by `public_id`
+ * @see https://cloudinary.com/documentation/admin_api#get_details_of_a_single_resource_by_public_id
+ * @param publicId - Cloudinary image public ID
+ * @param options
+ * @returns
+ */
+ async getResource (
+ publicId: string,
+ options?: AdminAndPublishOptions | undefined,
+ ) {
+ try {
+ return await cloudinary.api.resource(publicId, options)
+ } catch (err) {
+ return handleThrowError(err, METHODS.RESOURCE)
+ }
+ }
+
+ /**
+ * Deletes resources by a list of `public_ids`
+ * @see https://cloudinary.com/documentation/admin_api#delete_resources
+ * @param publicIds - List of `public_ids`
+ */
+ async deleteResources (publicIds: string[] = []) {
+ if (publicIds.length === 0) return
+
+ try {
+ const result = await cloudinary.api.delete_resources(publicIds, { invalidate: true })
+ console.log(result)
+ } catch (err) {
+ return handleThrowError(err, METHODS.DELETE_RESOURCES)
+ }
+ }
+}
diff --git a/app/src/lib/cloudinary/service.ts b/app/src/lib/cloudinary/service.ts
new file mode 100644
index 0000000..500b8b2
--- /dev/null
+++ b/app/src/lib/cloudinary/service.ts
@@ -0,0 +1,87 @@
+import { v2 as cloudinary } from 'cloudinary'
+import { handleThrowError, writeToFileBuffer } from '@/utils/helpers.js'
+import type { DeliveryType, ResourceType, UploadApiOptions } from 'cloudinary'
+
+const METHODS = {
+ UPLOAD: 'UPLOAD',
+ FETCH: 'FETCH',
+ DELETE: 'DELETE',
+}
+
+type DeleteOptions = {
+ resource_type?: ResourceType
+ type?: DeliveryType
+ invalidate?: boolean
+}
+
+type DeleteCallback = Parameters[1]
+
+/**
+ * Wrapper around the Cloudinary Upload API for uploading and fetching assets
+ * @see https://cloudinary.com/documentation/image_upload_api_reference
+ */
+export class AssetService {
+ /**
+ * Uploads an asset
+ * @param file - Local file path of asset to upload
+ * @param options - Cloudinary Uploader API `upload()` options
+ * @returns
+ */
+ async upload (file: string, options: UploadApiOptions) {
+ try {
+ return await cloudinary.uploader.upload(file, options)
+ } catch (err) {
+ return handleThrowError(err, METHODS.UPLOAD)
+ }
+ }
+
+ /**
+ * Deletes an asset
+ * @param publicId - Cloudinary image public ID
+ * @param options - Cloudinary Uploader API `destroy()` options
+ * @param callback - Function callback
+ * @returns
+ */
+ async delete (
+ publicId: string,
+ options?: DeleteOptions,
+ callback?: DeleteCallback,
+ ) {
+ try {
+ return await cloudinary.uploader.destroy(publicId, options, callback)
+ } catch (err) {
+ return handleThrowError(err, METHODS.DELETE)
+ }
+ }
+
+ /**
+ * Downloads a Cloudinary asset to local disk
+ * @param url - Public acessible Cloudinary URL to an asset
+ * @param filePath - Local file path in which to save the asset
+ * @param extension - (Optional) file extension for downloaded files
+ */
+ async fetch (url: string, filePath: string, extension?: string) {
+ if (!url) throw new Error('Undefined URL')
+ if (!filePath) throw new Error('Undefined filePath')
+ let downloadFilePath = filePath
+
+ if (extension) {
+ downloadFilePath = filePath.substring(0, filePath.lastIndexOf('.') + 1) + extension
+ }
+
+ try {
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch asset ${response.status}`)
+ }
+
+ const buffer = Buffer.from(await response.arrayBuffer())
+ writeToFileBuffer(downloadFilePath, buffer)
+
+ console.log(`[${METHODS.FETCH}]: File downloaded in\n${downloadFilePath}`)
+ } catch (err: unknown) {
+ handleThrowError(err, METHODS.FETCH)
+ }
+ }
+}
diff --git a/app/src/lib/cloudinary/transform.ts b/app/src/lib/cloudinary/transform.ts
new file mode 100644
index 0000000..c51f3ae
--- /dev/null
+++ b/app/src/lib/cloudinary/transform.ts
@@ -0,0 +1,172 @@
+import { v2 as cloudinary } from 'cloudinary'
+import { handleThrowError } from '@/utils/helpers.js'
+import type {
+ ImageTransformationOptions,
+ TransformationOptions,
+} from '@/types/types.js'
+
+const METHODS = {
+ GENERAL: 'GENERAL TRANSFORM',
+ RESIZE: 'RESIZE',
+ CROP: 'CROP',
+ FORMAT: 'FORMAT',
+ QUALITY: 'QUALITY',
+}
+
+/**
+ * Wrapper around the Cloudinary URL API for image transformations
+ * @see https://cloudinary.com/documentation/node_image_manipulation
+ * @see https://cloudinary.com/documentation/image_transformations
+ */
+export class Transform {
+ /**
+ * Cloudinary.url() transformation wrapper
+ * @param publicId - Cloudinary image public ID
+ * @param transformation - Cloudinary URL transformation options
+ * @returns
+ */
+ async transform (publicId: string, transformation: TransformationOptions) {
+ try {
+ return await cloudinary.url(publicId, { transformation })
+ } catch (err) {
+ return handleThrowError(err, METHODS.GENERAL)
+ }
+ }
+
+ /**
+ * Changes an asset's size by editing its width and/or height
+ * @param publicId - Cloudinary image public ID
+ * @param options - `ImageSize` Cloudinary URL transformation options
+ * @param options.width - Image width to resize
+ * @param options.height - Image height to resize
+ * @returns Cloudinary image URL of the resized image
+ */
+ async resize (
+ publicId: string,
+ options: ImageTransformationOptions,
+ ) {
+ const { width, height } = options
+ const hasWidth = Boolean(width)
+ const hasHeight = Boolean(height)
+
+ if (!hasWidth && !hasHeight) {
+ throw new Error('One of width or height is required')
+ }
+
+ const transformation: ImageTransformationOptions = {
+ ...(hasWidth && { width }),
+ ...(hasHeight && { height }),
+ crop: 'scale',
+ }
+
+ try {
+ const result = await this.transform(publicId, transformation)
+ console.log(`[${METHODS.RESIZE}]: Success`, result)
+
+ return result
+ } catch (err: unknown) {
+ return handleThrowError(err, METHODS.RESIZE)
+ }
+ }
+
+ /**
+ * Crops an image
+ * @param publicId - Cloudinary image public ID
+ * @param options - `ImageCropOptions` Cloudinary URL transformation options for
+ * @param options.width - Image width to resize
+ * @param options.height - Image height to resize
+ * @param options.crop - Decides how the image will fill the width and height.
+ * Possible values: scale | pad | thumb | fill
+ * @param options.gravity - Gravitates focus to the most important part of the picture.
+ * Possible values: auto | face | south_west | southe_east | north_east, etc
+ * @returns Cloudinary URL of the cropped image
+ */
+ async crop (
+ publicId: string,
+ options: ImageTransformationOptions,
+ ) {
+ if (!('width' in options) || !('height' in options)) {
+ throw new Error(`${METHODS.CROP}: Required width and height`)
+ }
+
+ const {
+ width,
+ height,
+ crop = 'pad',
+ } = options
+
+ try {
+ const result = await this.transform(publicId, {
+ width,
+ height,
+ crop,
+ })
+
+ console.log(`[${METHODS.CROP}]: Success`, result)
+
+ return result
+ } catch (error) {
+ return handleThrowError(error, METHODS.CROP)
+ }
+ }
+
+ /**
+ * Convert assets to other formats.
+ * Specify image and video format, eg., on native mobile based on device capabilities.
+ * Allows cloudinary to deliver the optimal format for
+ * web delivery scenarios with f_auto according to device
+ * @param publicId - Cloudinary image public ID
+ * @param format - Image format
+ * @returns
+ */
+ async format (
+ publicId: string,
+ format: string = 'auto',
+ options?: ImageTransformationOptions,
+ ) {
+ try {
+ const transformation = {
+ fetch_format: format,
+ ...(options && { options }),
+ }
+
+ const result = await this.transform(publicId, transformation)
+ console.log(`[${METHODS.FORMAT}]: Success`, result)
+
+ return result
+ } catch (error) {
+ return handleThrowError(error, METHODS.FORMAT)
+ }
+ }
+
+ /**
+ * Controls the visual quality and compression level of assets.
+ * Allows Cloudinary to deliver the optimal quality for each viewing device with q_auto
+ * @param publicId - Cloudinary image public ID
+ * @param quality - Cloudinary Image quality
+ * @param options - Cloudinary Image transform options
+ * @returns
+ */
+ async quality (
+ publicId: string,
+ quality: string = 'auto',
+ options?: ImageTransformationOptions[],
+ ) {
+ try {
+ let transformation: ImageTransformationOptions[] = []
+
+ if (Array.isArray(options)) {
+ transformation = [...options]
+ }
+
+ transformation.push({ quality })
+
+ const result = await this.transform(publicId, transformation)
+ console.log(`[${METHODS.QUALITY}]: Success`, result)
+
+ return result
+ } catch (error) {
+ return handleThrowError(error, METHODS.QUALITY)
+ }
+ }
+}
diff --git a/app/src/lib/image.ts b/app/src/lib/image.ts
new file mode 100644
index 0000000..dcded19
--- /dev/null
+++ b/app/src/lib/image.ts
@@ -0,0 +1,109 @@
+import { BaseImage } from './cloudinary/baseimage.js'
+import { AssetService } from './cloudinary/service.js'
+import { Transform } from './cloudinary/transform.js'
+import { AssetManager } from './cloudinary/manager.js'
+import { handleThrowError } from '@/utils/helpers.js'
+
+import type { UploadApiResponse } from 'cloudinary'
+import type { ImageTransformationOptions, ImageLocation } from '@/types/types.js'
+
+const METHODS = {
+ UPLOAD: 'CLOUD-IMAGE-UPLOAD',
+ OPTIMIZE: 'CLOUD-IMAGE-OPTIMIZE',
+ DELETE: 'CLOUD-IMAGE-DELETE',
+}
+
+/**
+ * Cloudinary image methods with local image file definitions
+ */
+export class CloudinaryImage extends BaseImage {
+ service = new AssetService()
+ manager = new AssetManager()
+ transformer = new Transform()
+
+ meta: UploadApiResponse | null = null
+
+ constructor (options: ImageLocation) {
+ super(options)
+ }
+
+ /**
+ * Uploads the `localFile` image to the `cloudinaryAssetFolder`
+ * @param tags - Comma-separated string tags associate with the image
+ */
+ async upload (tags?: string) {
+ try {
+ this.log(`Uploading ${this.location.localFile} to\n${this.cloudinaryAssetFolder}`)
+
+ const response = await this.service.upload(this.location.localFile, {
+ public_id: this.publicId,
+ asset_folder: this.cloudinaryAssetFolder,
+ ...(tags && { tags }),
+ })
+
+ this.meta = response
+
+ return response
+ } catch (error) {
+ return handleThrowError(error, METHODS.UPLOAD)
+ }
+ }
+
+ /**
+ * Optimizes the image uploaded in `localFile` in `cloudinaryAssetFolder` by
+ * resizing it to max 800px (or retaining size if < 800) and converting to webp.
+ * Downloads the optimized image into a `"processed"` folder relative to the `localFile`
+ * @param customWidth - Custom image width
+ * @returns
+ */
+ async optimize (customWidth: number = 800) {
+ try {
+ this.log('Starting optimization...')
+ this.log('Fetching Cloudinary resource...')
+
+ const maxWidth = 800
+ const resource = await this.manager.getResource(this.location.publicId!)
+ const { public_id, width } = resource
+
+ const transformOptions: ImageTransformationOptions[] = []
+
+ if (width > maxWidth) {
+ transformOptions.push({
+ width: customWidth,
+ crop: 'scale',
+ })
+ }
+
+ transformOptions.push({
+ fetch_format: 'webp',
+ })
+
+ this.log('Generating optimized image URL...')
+ const cloudinaryURL = await this.transformer.quality(
+ public_id,
+ 'auto',
+ transformOptions,
+ )
+
+ this.log('Fetching optimized image...')
+
+ return await this.service.fetch(cloudinaryURL, this.location.localDestination!, 'webp')
+ } catch (error) {
+ handleThrowError(error, METHODS.OPTIMIZE)
+ }
+ }
+
+ /**
+ * Deletes this image asset
+ */
+ async delete () {
+ try {
+ this.log(`Deleting ${this.publicId}...`)
+ const result = await this.service.delete(this.publicId, { invalidate: true })
+
+ console.log(result)
+ } catch (err) {
+ handleThrowError(err, METHODS.DELETE)
+ }
+ }
+}
diff --git a/app/src/main.ts b/app/src/main.ts
deleted file mode 100644
index 371fdfb..0000000
--- a/app/src/main.ts
+++ /dev/null
@@ -1 +0,0 @@
-console.log('hello')
diff --git a/app/src/scripts/envinfo/envinfo.ts b/app/src/scripts/envinfo/envinfo.ts
new file mode 100644
index 0000000..7689ee7
--- /dev/null
+++ b/app/src/scripts/envinfo/envinfo.ts
@@ -0,0 +1,15 @@
+import { execSync } from 'child_process'
+
+export const envinfo = () => {
+ console.log('Node version:', process.version)
+ console.log('Platform:', process.platform)
+ console.log('Arch:', process.arch)
+ console.log('V8 version:', process.versions.v8)
+
+ try {
+ console.log('npm version:', execSync('npm -v').toString().trim())
+ } catch {
+ console.log('npm version: unavailable')
+ }
+}
+
diff --git a/app/src/scripts/envinfo/index.ts b/app/src/scripts/envinfo/index.ts
new file mode 100644
index 0000000..1c236c9
--- /dev/null
+++ b/app/src/scripts/envinfo/index.ts
@@ -0,0 +1,3 @@
+import { envinfo } from './envinfo.js'
+
+envinfo()
diff --git a/app/src/scripts/optimize/index.ts b/app/src/scripts/optimize/index.ts
new file mode 100644
index 0000000..9bb4e7a
--- /dev/null
+++ b/app/src/scripts/optimize/index.ts
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+
+import { parseArgs } from 'node:util'
+import { optimize } from './optimize.js'
+
+const { values } = parseArgs({
+ args: process.argv.slice(2),
+ allowPositionals: true,
+ options: {
+ file: { // Path to input image file
+ type: 'string',
+ short: 'f',
+ },
+ assetfolder: { // Cloudinary asset folder (optional). Defaults to "image-optimizer"
+ type: 'string',
+ short: 'a',
+ },
+ outputFolder: { // Local image download folder (optional)
+ type: 'string',
+ short: 'o',
+ },
+ tags: { // Comma-separated Cloudinary image tags (optional)
+ type: 'string',
+ short: 't',
+ },
+ width: { // Image width to resize the input image (optional), default=800px
+ type: 'string',
+ short: 'w',
+ },
+ upload: { // Flag to upload the input inmage to Cloudinary.
+ type: 'boolean', // Required on first-time, optional on succeeding runs.
+ short: 'u',
+ },
+ deleteAfter: { // Flag to delete the uploaded image in Cloudinary (optional)
+ type: 'boolean',
+ short: 'd',
+ },
+ },
+})
+
+if (process.env.IS_DOCKER) {
+ setTimeout(() => {
+ optimize(values)
+ }, 5000)
+} else {
+ optimize(values)
+}
diff --git a/app/src/scripts/optimize/optimize.ts b/app/src/scripts/optimize/optimize.ts
new file mode 100644
index 0000000..a224dfb
--- /dev/null
+++ b/app/src/scripts/optimize/optimize.ts
@@ -0,0 +1,58 @@
+import { resolve } from 'node:path'
+import dotenv from 'dotenv'
+
+import { CloudinaryImage } from '@/lib/image.js'
+import { handleLogError } from '@/utils/helpers.js'
+
+dotenv.config()
+
+const log = (str: string) => {
+ console.log(`[OPTIMIZE] ${str}`)
+}
+
+export const optimize = async (args: Record) => {
+ const {
+ file = '',
+ assetfolder = 'image-optimizer',
+ outputFolder = '',
+ tags = '',
+ width = 800,
+ upload = false,
+ deleteAfter = false,
+ } = args
+
+ const filePath = String(file)
+ let outDir = null
+
+ if (outputFolder) {
+ outDir = resolve(String(outputFolder))
+ }
+
+ log(`Input file:\n${filePath}`)
+ log(`Output folder: ${outDir}`)
+ log(`Cloudinary asset folder: ${assetfolder}`)
+ log(`Tags: ${tags}`)
+ log(`Image width: ${width}`)
+ log(` -upload? ${upload}`)
+ log(` -delete cloud file? ${deleteAfter}\n`)
+
+ try {
+ const image = new CloudinaryImage({
+ localFile: filePath,
+ cloudinaryAssetFolder: String(assetfolder),
+ ...(outDir && { localDestination: outDir }),
+ })
+
+ if (upload) {
+ await image.upload(String(tags))
+ }
+
+ await image.optimize(Number(width))
+
+ if (deleteAfter) {
+ await image.delete()
+ }
+ } catch (err) {
+ handleLogError(err, 'ERROR')
+ }
+}
diff --git a/app/src/types/types.ts b/app/src/types/types.ts
new file mode 100644
index 0000000..5187b6d
--- /dev/null
+++ b/app/src/types/types.ts
@@ -0,0 +1,73 @@
+import type { TransformationOptions, ConfigAndUrlOptions } from 'cloudinary'
+export type { ImageTransformationOptions } from 'cloudinary'
+export type { TransformationOptions, ConfigAndUrlOptions }
+
+// ----------------------------------------------------
+// Base Image types
+// ----------------------------------------------------
+
+export interface ImageLocation {
+ localFile: string;
+ localDestination?: string;
+ publicId?: string;
+ cloudinaryAssetFolder?: string;
+}
+
+// ----------------------------------------------------
+// File types
+// ----------------------------------------------------
+
+export interface File {
+ allowed_formats: 'jpg' | 'jpeg' | 'png' | 'webp' | 'bmp' | 'gif' | 'avif';
+ fetch_format: 'auto' | string;
+}
+
+export interface Filename {
+ use_filename?: boolean;
+ unique_filename?: boolean;
+}
+
+// ----------------------------------------------------
+// Image Transformation types
+// ----------------------------------------------------
+
+// Custom transformation types
+
+export interface ImageSize {
+ width?: number;
+ height?: number;
+}
+
+export interface TransformOptionalParams {
+ crop?: string;
+ gravity?: string;
+ quality?: string;
+ fetch_format?: string;
+}
+
+export interface ImageCropOptions extends TransformOptionalParams {
+ width: number;
+ height: number;
+}
+
+export interface TransformOptions
+ extends ImageSize, TransformOptionalParams {}
+
+export interface TransformationStyles {
+ width?: number;
+ height?: number;
+ radius?: 'max' | number;
+ border?: string; // eg., 10px_solid_rgb:bde4fb
+ background?: 'auto' | string;
+ effect?: string; // eg., tint:40:red, improve:outdoor, art:zorro
+}
+
+export type CropOptions =
+ 'scale' | 'pad' | 'thumb' | 'fill'
+
+// Value could also be an object name in the image
+export type GravityOptions =
+ 'auto' | 'face' | 'auto' |
+ 'north' | 'north_east' | 'nort_west' |
+ 'south' | 'south_east' | 'south_west' |
+ 'east' | 'west'
diff --git a/app/src/utils/helpers.ts b/app/src/utils/helpers.ts
new file mode 100644
index 0000000..71746e3
--- /dev/null
+++ b/app/src/utils/helpers.ts
@@ -0,0 +1,161 @@
+import { promises as fs } from 'node:fs'
+import path, { basename, dirname, join } from 'node:path'
+import { fileURLToPath } from 'url'
+
+import dotenv from 'dotenv'
+
+/**
+ * Get the full file path of the current directory of a module file equivalent to `"__dirname"`
+ * @param {string} moduleFile - File URL of the current module being executed: `"import.meta.url"`
+ * @returns {string} Full file path to the directory of the calling file/module also know as `__dirname` in CommonJS
+ */
+export const directory = (moduleFile: string) => {
+ return dirname(fileURLToPath(moduleFile))
+}
+
+/**
+ * Copies files to an output directory
+ * @param outDir File path to the output directory
+ * @param files Array containing a list of file paths
+ */
+export const copyFiles = async (outDir: string, files: string[]) => {
+ await fs.mkdir(outDir, { recursive: true })
+
+ for (const src of files) {
+ const dest = path.join(outDir, path.basename(src))
+ await fs.copyFile(src, dest)
+ console.log(`Copied to ${dest}`)
+ }
+}
+
+/**
+ * Extracts the filename from a full file path
+ * @param pathToFile - Full file path to a local file
+ * @returns filename
+ */
+export const getFileName = (pathToFile: string) => {
+ return basename(pathToFile)
+}
+
+export const createDirectory = async (pathToFile: string, newDir: string = 'processed') => {
+ const assetDir = dirname(pathToFile)
+ const destDir = join(assetDir, newDir)
+
+ await fs.mkdir(destDir, { recursive: true })
+
+ return destDir
+}
+
+/**
+ * Writes a buffer to file on disk
+ * @param pathToFile - Full file path to a local file
+ * @param buffer
+ */
+export const writeToFileBuffer = (pathToFile: string, buffer: Buffer) => {
+ fs.writeFile(pathToFile, buffer)
+}
+
+/**
+ * Loads the `.env` environment variable from a path
+ * @param {string} pathToEnv - Path to a `.env` file
+ */
+export const loadEnv = (pathToEnv: string | undefined) => {
+ if (!pathToEnv) return
+
+ dotenv.config({
+ path: pathToEnv,
+ quiet: true,
+ })
+}
+
+/**
+ * Re-throws an Error with log from calling function
+ * @param error - Error object
+ * @param prefix - Calling function identifier
+ */
+export const handleThrowError = (error: unknown, prefix: string = 'LOG'): never => {
+ // error is a legit Error
+ if (error instanceof Error) {
+ const msg = `[${prefix}] ${error.message}`
+ throw new Error(msg, { cause: error })
+ }
+
+ if ( // error is an Object
+ typeof error === 'object' &&
+ error !== null &&
+ 'message' in error
+ ) {
+ throw new Error(
+ `[${prefix}] ${error.message}`,
+ { cause: error },
+ )
+ }
+
+ if ( // error is an Object with an inner "error" object
+ typeof error === 'object' &&
+ error !== null &&
+ 'error' in error
+ ) {
+ const { error: innerError } = error
+ const { message } = innerError as { message: string; http_code: number }
+
+ throw new Error(
+ `[${prefix}] ${message}`,
+ { cause: error },
+ )
+ }
+
+ const msgUnknown = `[${prefix}] An unknown error occured`
+ throw new Error(msgUnknown, { cause: error })
+}
+
+/**
+ * Logs an Error with log from calling function
+ * @param error - Error object
+ * @param prefix - Calling function identifier
+ */
+export const handleLogError = (error: unknown, prefix: string = 'LOG') => {
+ if (error instanceof Error) {
+ console.log(`[${prefix}] ${String(error.message)}`)
+ } else if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'message' in error
+ ) {
+ console.log(`[${prefix}] ${String(error.message)}`)
+ } else if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'error' in error
+ ) {
+ const { error: innerError } = error
+ const { message } = innerError as { message: string; http_code: number }
+ console.log(`[${prefix}] ${message}`)
+ } else {
+ console.log(`[${prefix}] An unknown error occured`)
+ }
+}
+
+/**
+ * Tiny arg parser for `--key=value` style flags
+ * @param {string[]} args - Raw args, typically process.argv.slice(2)
+ * @returns {Object} Parsed key/value pairs
+ */
+export const parseArgs = (args: string[]): Record => {
+ const result: Record = {}
+
+ for (const arg of args) {
+ if (!arg.startsWith('--')) continue
+
+ const [key, ...values] = arg.slice(2).split('=')
+
+ if (key) {
+ result[key] = values.length > 0
+ ? values.join('=')
+ : true // supports flags with no value, e.g. --verbose
+ }
+ }
+
+ return result
+}
+
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..a9f387a
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,16 @@
+# Docker compose file for building the production image only
+services:
+ cloudinary-cli:
+ container_name: cloudinary-cli
+ image: weaponsforge/cloudinary-cli
+ env_file:
+ - ./app/.env
+ build:
+ context: ./app
+ dockerfile: Dockerfile
+ target: production
+ volumes:
+ - ./app/assets:/opt/app/assets
+ - /opt/app/node_modules
+ environment:
+ - NODE_ENV=production
diff --git a/docker-compose.yml b/docker-compose.yml
index 290aa41..63ae2e8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,16 +1,16 @@
# Docker compose file for local development mode
services:
- cloudinary-cli:
- container_name: cloudinary-cli
+ cloudinary-cli-dev:
+ container_name: cloudinary-cli-dev
image: weaponsforge/cloudinary-cli:dev
env_file:
- - ./.env
+ - ./app/.env
build:
- context: .
+ context: ./app
dockerfile: Dockerfile
target: development
volumes:
- - .:/opt/app
+ - ./app:/opt/app
- /opt/app/node_modules
ports:
- "9229:9229" # VSCode debugger
diff --git a/docs/README_NPM.md b/docs/README_NPM.md
new file mode 100644
index 0000000..6c31bb4
--- /dev/null
+++ b/docs/README_NPM.md
@@ -0,0 +1,148 @@
+## cloudinary-image
+
+Cloudinary image optimization CLI with Node.js wrappers for select [Cloudinary Node.js APIs](https://cloudinary.com/documentation/node_image_manipulation)
+covering image transformation, optimization, and asset management.
+
+### Requirements
+
+- Node v24+
+- Cloudinary account
+
+## ๐ Quickstart
+
+1. Install the library.
+
+ ```sh
+ npm i cloudinary-image
+ ```
+
+2. Create a `.env` file in the `/app` directory, replacing the contents of the `.env.example` file with actual values.
+
+ | Variable Name | Description |
+ | --- | --- |
+ | CLOUDINARY_NAME | Cloudinary account name |
+ | CLOUDINARY_API_KEY | Cloudinary API key |
+ | CLOUDINARY_API_SECRET | Cloudinary API secret |
+
+3. Optimize images programmatically via code. See the examples under the [Code Samples](#-code-samples) section for more information.
+
+
+
+## ๐งพ Code Samples
+
+### A. Optimize an Image
+
+```typescript
+import { join } from 'node:path'
+import dotenv from 'dotenv'
+import { CloudinaryImage } from 'cloudinary-image'
+
+dotenv.config()
+
+const main = async () => {
+ const filePath = join(process.cwd(), 'boat.jpg')
+
+ const image = new CloudinaryImage({
+ localFile: filePath,
+ cloudinaryAssetFolder: 'my-folder',
+ })
+
+ await image.upload('sea,travel')
+ await image.optimize(600)
+ await image.delete()
+}
+
+main()
+```
+
+### B. Apply Image Transformations
+
+```typescript
+import dotenv from 'dotenv'
+import { CloudinaryImage } from 'cloudinary-image'
+import { join } from 'node:path'
+
+dotenv.config()
+
+const main = async () => {
+ const filePath = join(process.cwd(), 'boat.jpg')
+
+ const image = new CloudinaryImage({
+ localFile: filePath,
+ cloudinaryAssetFolder: 'my-folder',
+ })
+
+ // Upload image to Cloudinary
+ await image.upload('sea,travel')
+
+ // Generate URL of resized image
+ const urlResize = await image.transformer
+ .resize(image.publicId, {
+ width: 450
+ })
+
+ // Generate URL of cropped image
+ const urlCropped = await image.transformer
+ .crop(image.publicId, {
+ width: 400,
+ height: 200,
+ crop: 'scale'
+ })
+
+ // Generate URL of image's new format
+ const urlFormat = await image.transformer
+ .format(image.publicId, 'webp')
+
+ // Generate URL of image with improved quality
+ const urlQuality = await image.transformer
+ .quality(image.publicId, 'auto')
+
+ // Download one of the generated images
+ const downloadFilePath = join(process.cwd(), image.name)
+ await image.service.fetch(urlCropped, downloadFilePath)
+}
+
+main()
+```
+
+### C. Using Classes
+
+```typescript
+import { join } from 'node:path'
+
+import {
+ AssetManager,
+ AssetService,
+ BaseImage,
+ Transform
+} from 'cloudinary-image'
+
+// Class for managing Cloudinary assets
+const _manager = new AssetManager()
+
+// Class for uploading and fetching images from Cloudinary
+const _service = new AssetService()
+
+// Class for generating Cloudinary image transformations
+const _transformer = new Transform()
+
+// Initialize a new BaseImage - no Cloudinary libraries
+const inputFile = join(process.cwd(), 'boat.jpg')
+const outputFile = join(process.cwd(), 'images', 'done', 'processed.jpg')
+
+const _image = new BaseImage({
+ localFile: inputFile,
+ cloudinaryAssetFolder: 'my-folder',
+ localDestination: outputFile, // optional
+})
+
+// Note: the CloudinaryImage class is composed of all these components
+```
+
+## References
+
+- [Cloudinary NPM Registry](https://www.npmjs.com/package/cloudinary)
+- [Cloudinary Node.js Docs](https://cloudinary.com/documentation/node_image_manipulation)
+
+@weaponsforge
+20260819