├── .eslintrc
├── .gitignore
├── .prettierrc
├── README.md
├── bin
└── index.js
├── package-lock.json
├── package.json
└── src
├── config
├── serviceMappings.json
└── typeMappings.json
├── handlers
├── archiveHandler.js
├── audioHandler.js
├── codeHandler.js
├── imageHandler.js
├── jsonYamlHandler.js
├── pdfHandler.js
├── spreadsheetHandler.js
└── textHandler.js
├── services
├── gzipService.js
├── tarService.js
└── zipService.js
└── utils
├── fileHandler.js
├── fileProcessor.js
├── files.js
└── rangeFetcher.js
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier"],
3 | "rules": {
4 | "prettier/prettier": "off",
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "printWidth": 100
4 | }
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 Zip stream CLI
2 |
3 | [](https://www.npmjs.com/package/zip-stream-cli)
4 |
5 | **Zip stream CLI** is a Node.js library that allows you to extract and display content from various file types inside a zip or tar archive directly in the terminal. The library supports multiple file types such as images, audio files, PDFs, text, spreadsheets, and more, with the option to extend functionality by adding new handlers.
6 |
7 | 
8 |
9 | ## ✨ Features
10 |
11 | - **Supports Multiple File Types**: Automatically detect and display content from various file types inside both zip and tar archives.
12 | - **Modular Handler System**:
13 | - Easily extend support for new file types by adding custom handlers.
14 | - Handlers for existing file types are dynamically loaded based on the file extension.
15 | - **🎵 Stream and Display Audio Waveforms**: Display waveforms for audio files directly in the terminal.
16 | - **🖼️ Display Images**: View images as pixel art directly in the terminal.
17 | - **⚙️ Customizable Output**: Each file type is displayed using appropriate handlers, allowing you to customize the way content is shown for different types of files.
18 |
19 | ## ✨ Simplified Extension Management
20 |
21 | - With the new **fileHandler** system, adding support for additional compressed file types (e.g., `.rar`, `.7z`, `.gzip`) becomes easier. New services can be created for each file type and mapped in the `serviceMappings.json` file.
22 | - Once the service is implemented and mapped, the file type can be automatically recognized and processed by the CLI.
23 | - This allows the system to be more extensible without the need for significant modifications to the core logic, making it easy to add support for new file types as needed.
24 |
25 | ## ⚡ Installation dev
26 |
27 | 1. Clone this repository to your local machine:
28 |
29 | ```bash
30 | git clone https://github.com/agarrec-vivlio/zip-stream-cli
31 | cd zip-stream-cli
32 | ```
33 |
34 | 2. Install the required dependencies:
35 |
36 | ```bash
37 | npm install
38 | ```
39 |
40 | 3. Link the project globally using npm link:
41 |
42 | ```bash
43 | npm link
44 | ```
45 |
46 | ## ⚡ Installation global
47 |
48 | You can also install globally using npm:
49 |
50 | ```bash
51 | npm install -g zip-stream-cli
52 | ```
53 |
54 | ## 🌐 Global Usage
55 |
56 | Once installed globally or linked, you can run the `zip-stream-cli` command from anywhere in your terminal.
57 |
58 | ### Example:
59 |
60 | ```bash
61 | zip-stream-cli https://example.com/myarchive.zip
62 | zip-stream-cli https://example.com/myarchive.tar.gz
63 | ```
64 |
65 | ## 🛠️ File Type Handlers
66 |
67 | The library dynamically loads file handlers based on the file extension. Handlers for various file types are stored in the `handlers` directory.
68 |
69 | The `typeMappings.json` file maps file extensions to their respective handlers. If a file type is not recognized or doesn't have a dedicated handler, it falls back to the `textHandler` to display the file as plain text.
70 |
71 | ### Supported File Types
72 |
73 | | File Type | Extensions | Handler |
74 | | ----------------- | ---------------------------------- | -------------------- |
75 | | Text Files | `.txt`, `.md`, `.html` | `textHandler` |
76 | | Audio Files | `.mp3`, `.wav`, `.ogg` | `audioHandler` |
77 | | Image Files | `.png`, `.jpg`, `.gif`, `.bmp` | `imageHandler` |
78 | | PDF Files | `.pdf` | `pdfHandler` |
79 | | Spreadsheet Files | `.xls`, `.xlsx`, `.csv` | `spreadsheetHandler` |
80 | | Code Files | `.js`, `.py`, `.java`, `.rb`, etc. | `codeHandler` |
81 | | Archive Files | `.zip`, `.tar`, `.gz` | `archiveHandler` |
82 | | YAML & JSON Files | `.yaml`, `.yml`, `.json` | `jsonYamlHandler` |
83 |
84 | ### Adding a New File Type
85 |
86 | The system is designed to be extensible, making it easy to add new handlers for different file types. Follow the steps below to add support for a new file type.
87 |
88 | ### Step 1: Create a New Handler
89 |
90 | To add support for a new file type, create a new handler file inside the `handlers` directory.
91 |
92 | Example: Create `customFileHandler.js` to handle a new file type, say `.custom`.
93 |
94 | ```javascript
95 | // handlers/customFileHandler.js
96 | module.exports = async function handleCustomFile(fileStream) {
97 | const chunks = [];
98 |
99 | for await (const chunk of fileStream) {
100 | chunks.push(chunk);
101 | }
102 |
103 | const fileContent = Buffer.concat(chunks).toString("utf-8");
104 | console.log("Displaying custom file content:");
105 | console.log(fileContent); // Replace this with your custom logic to handle the file
106 | };
107 | ```
108 |
109 | ### Step 2: Update `typeMappings.json`
110 |
111 | Add the new file extension and map it to the newly created handler in `typeMappings.json`.
112 |
113 | ```json
114 | {
115 | "custom": "customFileHandler",
116 | "txt": "textHandler",
117 | "md": "textHandler",
118 | "json": "jsonYamlHandler",
119 | "yaml": "jsonYamlHandler",
120 | "mp3": "audioHandler",
121 | "wav": "audioHandler",
122 | "png": "imageHandler",
123 | "jpg": "imageHandler"
124 | }
125 | ```
126 |
127 | ### Step 3: Use Your Custom Handler
128 |
129 | Now, when a file with the `.custom` extension is encountered, the library will use your `customFileHandler.js` to process and display the file.
130 |
131 | ## 📄 TAR File Streaming
132 |
133 | In TAR file handling, the **Zip stream CLI** employs a streaming approach to efficiently process large archives without requiring the entire file to be downloaded and stored in memory.
134 |
135 | ### How TAR File Streaming Works:
136 |
137 | 1. **Partial Fetching**: For uncompressed TAR files, the CLI fetches small chunks of the file (e.g., a few megabytes at a time). For compressed `.tar.gz` files, compressed chunks are fetched and decompressed on the fly. This allows the CLI to start listing or extracting files without needing the entire archive.
138 |
139 | 2. **Entry-by-Entry Processing**: The TAR archive is processed entry by entry, reading file headers and skipping over data unless it is necessary for the current operation. This keeps memory usage low.
140 |
141 | 3. **File Extraction**: When extracting a specific file, the CLI fetches the portion of the TAR file where the file is located and decompresses only that part (if necessary). The rest of the archive is skipped.
142 |
143 | 4. **Efficient for Large Archives**: The CLI uses the `tar-stream` library to process entries without buffering the whole file. Compressed archives use `zlib` to decompress data in chunks.
144 |
145 | ### Advantages:
146 |
147 | - **Memory Efficiency**: Only the needed parts of the archive are processed, avoiding the need to load the entire archive into memory.
148 | - **Streaming**: Files are processed as they are streamed in, improving performance on large files.
149 | - **Optimized for Compressed Archives**: Compressed TAR files (`.tar.gz`) are streamed and decompressed incrementally.
150 |
151 | ## 📸 Screenshots
152 |
153 | - **File Listing**:
154 |
155 |
156 |
157 | - **🖼️ Image file output**:
158 |
159 |
160 |
161 | - **📄 Text file output**:
162 |
163 |
164 |
165 | ## 🤝 Contributing
166 |
167 | Contributions are welcome! Feel free to fork the repository, create new handlers, fix bugs, or add new features.
168 |
169 | To contribute:
170 |
171 | 1. Fork this repository.
172 | 2. Create a new branch (`git checkout -b feature-new-handler`).
173 | 3. Add your feature or fix.
174 | 4. Push your branch and submit a pull request.
175 |
176 | ## 📜 License
177 |
178 | This project is licensed under the MIT License.
179 |
--------------------------------------------------------------------------------
/bin/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const inquirer = require("inquirer");
4 | const validUrl = require("valid-url");
5 | const getFileService = require("../src/utils/fileProcessor");
6 | const { getFileType } = require("../src/utils/files");
7 |
8 | const listAndSelectFile = async (files) => {
9 | const choices = files.map((file) => ({
10 | name: `${file.filename} (${file.fileSize || "unknown"} bytes)`,
11 | value: file,
12 | }));
13 |
14 | if (!choices.length) {
15 | console.log("No files found in the archive.");
16 | process.exit(0);
17 | }
18 |
19 | const { selectedFile } = await inquirer.prompt([
20 | {
21 | type: "list",
22 | name: "selectedFile",
23 | message: "Select a file to display or process:",
24 | choices,
25 | },
26 | ]);
27 |
28 | return selectedFile;
29 | };
30 |
31 | const main = async () => {
32 | try {
33 | const [url] = process.argv.slice(2);
34 |
35 | if (!url || !validUrl.isWebUri(url)) {
36 | console.error("Usage: ");
37 | process.exit(1);
38 | }
39 |
40 | const fileType = getFileType(url);
41 |
42 | const fileService = await getFileService(fileType);
43 |
44 | const files = await fileService.listFiles(url);
45 |
46 | const selectedFile = await listAndSelectFile(files);
47 |
48 | await fileService.processFile(selectedFile, url);
49 | //
50 | } catch (error) {
51 | console.error("An error occurred during processing:", error);
52 | process.exit(1);
53 | }
54 | };
55 |
56 | main();
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zip-stream-cli",
3 | "version": "2.0.1",
4 | "description": "A tool to extract files from remote zip archives over HTTP.",
5 | "main": "src/RemoteZipFile.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/agarrec-vivlio/zip-stream-cli.git"
9 | },
10 | "author": "Alexandre-g (https://github.com/agarrec-vivlio)",
11 | "bin": {
12 | "zip-stream-cli": "./bin/index.js"
13 | },
14 | "scripts": {
15 | "start": "node bin/index.js",
16 | "release": "release-it"
17 | },
18 | "license": "MIT",
19 | "dependencies": {
20 | "asciify-image": "^0.1.10",
21 | "audio-decode": "^2.2.2",
22 | "audio-loader": "^1.0.3",
23 | "chalk": "^5.3.0",
24 | "cli-chart": "^0.3.1",
25 | "cli-progress": "^3.12.0",
26 | "inquirer": "^8.2.0",
27 | "js-yaml": "^4.1.0",
28 | "keypress": "^0.2.1",
29 | "node-fetch": "^2.6.1",
30 | "ora": "^8.1.0",
31 | "pdf-parse": "^1.1.1",
32 | "readline": "^1.3.0",
33 | "speaker": "^0.5.5",
34 | "stream": "^0.0.3",
35 | "tar-stream": "^3.1.7",
36 | "terminal-image": "^3.0.0",
37 | "unzipper": "^0.12.3",
38 | "valid-url": "^1.0.9",
39 | "xlsx": "^0.18.5"
40 | },
41 | "release-it": {
42 | "$schema": "https://unpkg.com/release-it/schema/release-it.json",
43 | "github": {
44 | "release": true
45 | }
46 | },
47 | "devDependencies": {
48 | "prettier": "^3.3.3",
49 | "release-it": "^17.6.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/config/serviceMappings.json:
--------------------------------------------------------------------------------
1 | {
2 | "zip": "zipService",
3 | "tar": "tarService",
4 | "rar": "rarService",
5 | "gz": "gzipService",
6 | "t7": "t7Service"
7 | }
8 |
--------------------------------------------------------------------------------
/src/config/typeMappings.json:
--------------------------------------------------------------------------------
1 | {
2 | "txt": "textHandler",
3 | "md": "textHandler",
4 | "json": "jsonYamlHandler",
5 | "yaml": "jsonYamlHandler",
6 | "yml": "jsonYamlHandler",
7 | "mp3": "audioHandler",
8 | "wav": "audioHandler",
9 | "ogg": "audioHandler",
10 | "png": "imageHandler",
11 | "jpg": "imageHandler",
12 | "jpeg": "imageHandler",
13 | "gif": "imageHandler",
14 | "bmp": "imageHandler",
15 | "pdf": "pdfHandler",
16 | "xls": "spreadsheetHandler",
17 | "xlsx": "spreadsheetHandler",
18 | "csv": "spreadsheetHandler",
19 | "zip": "archiveHandler",
20 | "tar": "archiveHandler",
21 | "gz": "archiveHandler",
22 | "js": "codeHandler",
23 | "py": "codeHandler",
24 | "java": "codeHandler",
25 | "rb": "codeHandler"
26 | }
27 |
--------------------------------------------------------------------------------
/src/handlers/archiveHandler.js:
--------------------------------------------------------------------------------
1 | const unzipper = require("unzipper");
2 |
3 | module.exports = async function handleArchiveFile(fileStream) {
4 | fileStream.pipe(unzipper.Parse()).on("entry", (entry) => {
5 | console.log(`File: ${entry.path}, Type: ${entry.type}`);
6 | entry.autodrain();
7 | });
8 | };
9 |
--------------------------------------------------------------------------------
/src/handlers/audioHandler.js:
--------------------------------------------------------------------------------
1 | const load = require("audio-loader");
2 | const Speaker = require("speaker");
3 | const readline = require("readline");
4 | const cliProgress = require("cli-progress");
5 |
6 | /**
7 | * Processes and plays an audio stream with real-time playback progress shown in the CLI.
8 | * The function buffers the audio stream, plays it through the speaker, and displays
9 | * a progress bar in the terminal to track playback. The process can be stopped with `Ctrl+C`.
10 | *
11 | * @param {stream.Readable} audioStream - The audio stream to process and play.
12 | *
13 | * @returns {Promise} A promise that resolves when the audio playback is completed.
14 | */
15 | async function handleAudioStreamWithPlayPause(audioStream) {
16 | const chunks = [];
17 |
18 | for await (const chunk of audioStream) {
19 | chunks.push(chunk);
20 | }
21 |
22 | const audioBuffer = Buffer.concat(chunks);
23 | const audioData = await load(audioBuffer);
24 |
25 | const speaker = new Speaker({
26 | channels: audioData.numberOfChannels,
27 | bitDepth: 16,
28 | sampleRate: audioData.sampleRate,
29 | });
30 |
31 | let currentSampleIndex = 0;
32 | const totalSamples = audioData.length * audioData.numberOfChannels;
33 |
34 | const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
35 | progressBar.start(totalSamples, 0);
36 |
37 | const writeAudioToSpeaker = () => {
38 | const sampleData = audioData.getChannelData(0);
39 |
40 | const writeData = () => {
41 | if (currentSampleIndex < sampleData.length) {
42 | const chunk = Buffer.alloc(2);
43 | const sample = Math.max(-1, Math.min(1, sampleData[currentSampleIndex++]));
44 | chunk.writeInt16LE(sample * 32767, 0);
45 |
46 | if (!speaker.write(chunk)) {
47 | speaker.once("drain", writeData);
48 | } else {
49 | setImmediate(writeData);
50 | }
51 |
52 | progressBar.update(currentSampleIndex);
53 | } else {
54 | speaker.end();
55 | progressBar.stop();
56 | process.stdin.setRawMode(false);
57 | process.stdin.pause();
58 | }
59 | };
60 |
61 | writeData();
62 | };
63 |
64 | const rl = readline.createInterface({
65 | input: process.stdin,
66 | output: process.stdout,
67 | });
68 |
69 | readline.emitKeypressEvents(process.stdin);
70 | process.stdin.setRawMode(true);
71 |
72 | process.stdin.on("keypress", (key) => {
73 | if (key.ctrl && key.name === "c") {
74 | progressBar.stop();
75 | speaker.end();
76 | process.stdin.setRawMode(false);
77 | process.exit();
78 | }
79 | });
80 |
81 | writeAudioToSpeaker();
82 | }
83 |
84 | module.exports = handleAudioStreamWithPlayPause;
85 |
--------------------------------------------------------------------------------
/src/handlers/codeHandler.js:
--------------------------------------------------------------------------------
1 | const readline = require("readline");
2 | const chalk = require("chalk");
3 |
4 | // Function to handle code files and syntax-highlight them in the terminal
5 | module.exports = async function handleCodeFile(fileStream) {
6 | const rl = readline.createInterface({
7 | input: fileStream,
8 | output: process.stdout,
9 | terminal: false,
10 | });
11 |
12 | rl.on("line", (line) => {
13 | console.log(chalk.green(line)); // Syntax highlight lines (simple green)
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/src/handlers/imageHandler.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const fs = require("fs");
3 | const { pipeline } = require("stream/promises");
4 |
5 | // Function to dynamically import terminal-image
6 | async function getTerminalImage() {
7 | const terminalImage = await import("terminal-image");
8 | return terminalImage.default;
9 | }
10 |
11 | // Function to handle image files and display them in the terminal
12 | module.exports = async function handleImageFile(fileStream) {
13 | const imagePath = path.join(__dirname, "temp_image.jpg");
14 | const writeStream = fs.createWriteStream(imagePath);
15 |
16 | try {
17 | await pipeline(fileStream, writeStream);
18 | const terminalImage = await getTerminalImage();
19 | const image = await terminalImage.file(imagePath, {
20 | width: "50%",
21 | height: "50%",
22 | });
23 | console.log(image);
24 | } catch (error) {
25 | console.error("Error processing image file:", error.message);
26 | } finally {
27 | if (fs.existsSync(imagePath)) {
28 | fs.unlinkSync(imagePath);
29 | }
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/src/handlers/jsonYamlHandler.js:
--------------------------------------------------------------------------------
1 | const yaml = require("js-yaml");
2 |
3 | module.exports = async function handleJsonYamlFile(fileStream, extension) {
4 | const chunks = [];
5 | for await (const chunk of fileStream) {
6 | chunks.push(chunk);
7 | }
8 | const data = Buffer.concat(chunks).toString();
9 |
10 | if (extension === "json") {
11 | console.log(JSON.stringify(JSON.parse(data), null, 2)); // Pretty-print JSON
12 | } else if (extension === "yaml" || extension === "yml") {
13 | const yamlData = yaml.load(data);
14 | console.log(yaml.dump(yamlData)); // Pretty-print YAML
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/src/handlers/pdfHandler.js:
--------------------------------------------------------------------------------
1 | const pdf = require("pdf-parse");
2 |
3 | module.exports = async function handlePdfFile(fileStream) {
4 | const chunks = [];
5 |
6 | for await (const chunk of fileStream) {
7 | chunks.push(chunk);
8 | }
9 |
10 | const buffer = Buffer.concat(chunks);
11 |
12 | try {
13 | const data = await pdf(buffer);
14 | console.log(data.text); // Display extracted text from PDF
15 | } catch (err) {
16 | console.error("Error parsing PDF:", err.message);
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/src/handlers/spreadsheetHandler.js:
--------------------------------------------------------------------------------
1 | const xlsx = require("xlsx");
2 |
3 | // Function to handle and display spreadsheet files (xls, xlsx, csv)
4 | module.exports = async function handleSpreadsheetFile(fileStream) {
5 | const chunks = [];
6 |
7 | for await (const chunk of fileStream) {
8 | chunks.push(chunk);
9 | }
10 |
11 | const buffer = Buffer.concat(chunks);
12 |
13 | try {
14 | const workbook = xlsx.read(buffer, { type: "buffer" });
15 | const sheetName = workbook.SheetNames[0];
16 | const sheet = workbook.Sheets[sheetName];
17 | const data = xlsx.utils.sheet_to_csv(sheet);
18 | console.log(data); // Display CSV representation of the sheet
19 | } catch (err) {
20 | console.error("Error processing spreadsheet:", err.message);
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/src/handlers/textHandler.js:
--------------------------------------------------------------------------------
1 | const readline = require("readline");
2 |
3 | // Function to handle and display text files
4 | module.exports = async function handleTextFile(fileStream) {
5 | const rl = readline.createInterface({
6 | input: fileStream,
7 | output: process.stdout,
8 | terminal: false,
9 | });
10 |
11 | rl.on("line", (line) => {
12 | console.log(line); // Print each line of the file
13 | });
14 | };
15 |
--------------------------------------------------------------------------------
/src/services/gzipService.js:
--------------------------------------------------------------------------------
1 | // WIP
2 | const zlib = require("zlib");
3 | const stream = require("stream");
4 | const path = require("path");
5 | const fetch = require("node-fetch");
6 | const { fetchByteRange } = require("../utils/rangeFetcher");
7 | const getFileHandler = require("../utils/fileHandler");
8 |
9 | /**
10 | * Extracts the original filename from the GZIP header if it exists.
11 | * @param {Buffer} gzipHeader - The GZIP header buffer.
12 | * @returns {string} - The original filename or 'unknown' if not present.
13 | */
14 | const extractGzipFilename = (gzipHeader) => {
15 | const FNAME_FLAG = 0x08; // The flag that indicates the presence of the original filename
16 |
17 | // Check if the FNAME flag is set (indicating that the original filename is included)
18 | if (gzipHeader[3] & FNAME_FLAG) {
19 | let offset = 10; // Filename starts after the 10-byte GZIP header
20 | let filename = "";
21 |
22 | // Iterate through the bytes after the header until we find a null terminator (0x00)
23 | while (gzipHeader[offset] !== 0x00 && offset < gzipHeader.length) {
24 | filename += String.fromCharCode(gzipHeader[offset]);
25 | offset++;
26 | }
27 |
28 | return filename || "unknown"; // Return the filename if found
29 | }
30 |
31 | return "unknown"; // Return 'unknown' if the FNAME flag is not set
32 | };
33 |
34 | /**
35 | * Lists files from a GZIP file. GZIP is typically used to compress a single file.
36 | * @param {string} url - The URL of the GZIP file.
37 | * @returns {Promise