pompelmi 1.11.0 → 1.12.1

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/README.md CHANGED
@@ -21,12 +21,28 @@
21
21
 
22
22
  ---
23
23
 
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ # Scan a file
28
+ npx pompelmi scan ./uploads/file.pdf
29
+
30
+ # Scan a directory
31
+ npx pompelmi scan ./uploads --recursive
32
+
33
+ # Output as JSON
34
+ npx pompelmi scan ./uploads --json
35
+ ```
36
+
37
+ ---
38
+
24
39
  ## Documentation
25
40
 
26
41
  | Guide | Description |
27
42
  |-------|-------------|
28
43
  | [Getting Started](./docs/getting-started.md) | Installation, prerequisites, quickstart examples |
29
44
  | [API Reference](./docs/api.md) | Full function signatures, options, verdicts, error conditions |
45
+ | [CLI Reference](./docs/cli.html) | Terminal commands, options, examples |
30
46
  | [S3 Integration](./docs/s3.md) | Scan S3 objects directly, IAM setup, Lambda pattern |
31
47
  | [Docker / Remote Scanning](./docs/docker.md) | TCP sidecar, UNIX socket mount, docker-compose patterns |
32
48
  | [GitHub Action](./docs/github-action.md) | CI scanning, inputs/outputs, caching, example workflows |
@@ -56,6 +72,7 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
56
72
 
57
73
  ## Features
58
74
 
75
+ - Standalone CLI — scan files from any terminal with `npx pompelmi scan`
59
76
  - Single `scan(filePath, [options])` function — works locally or against a remote clamd instance
60
77
  - `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
61
78
  - `scanStream(stream, [options])` — scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const readline = require('readline');
7
+
8
+ const pkg = require('../package.json');
9
+
10
+ // ── Logo ──────────────────────────────────────────────────────────────────────
11
+
12
+ async function printLogo() {
13
+ const titleLines = [
14
+ '\x1b[33mpompelmi\x1b[0m — ClamAV Antivirus Scanning for Node.js',
15
+ '\x1b[90mv' + pkg.version + ' • Zero dependencies • TCP • UNIX socket\x1b[0m',
16
+ ];
17
+
18
+ try {
19
+ const terminalImage = (await import('terminal-image')).default;
20
+ const imgPath = path.join(__dirname, '../src/grapefruit.png');
21
+ if (fs.existsSync(imgPath)) {
22
+ const image = await terminalImage.file(imgPath, {
23
+ width: 24,
24
+ preserveAspectRatio: true,
25
+ });
26
+
27
+ const rawLines = image.split('\n');
28
+ // Drop trailing blank line that terminal-image appends
29
+ const imgLines = rawLines[rawLines.length - 1].trim() === ''
30
+ ? rawLines.slice(0, -1)
31
+ : rawLines;
32
+ const pad = ' ';
33
+ const gap = ' ';
34
+ const maxRows = Math.max(imgLines.length, titleLines.length);
35
+
36
+ process.stdout.write('\n');
37
+ for (let i = 0; i < maxRows; i++) {
38
+ const imgPart = imgLines[i] ?? ' '.repeat(24);
39
+ const textPart = i === 0 ? titleLines[0]
40
+ : i === 1 ? titleLines[1]
41
+ : '';
42
+ process.stdout.write(pad + imgPart + gap + textPart + '\n');
43
+ }
44
+ process.stdout.write('\n');
45
+ return;
46
+ }
47
+ } catch (_) {}
48
+
49
+ // Fallback: text-only header
50
+ console.log('\n\x1b[33m pompelmi\x1b[0m — ClamAV Antivirus Scanning for Node.js');
51
+ console.log('\x1b[90m v' + pkg.version + ' • Zero dependencies • TCP • UNIX socket\x1b[0m\n');
52
+ }
53
+
54
+ // ── Argument parsing ──────────────────────────────────────────────────────────
55
+
56
+ function parseArgs(argv) {
57
+ const args = argv.slice(2);
58
+ const opts = {
59
+ command: null,
60
+ target: null,
61
+ host: undefined,
62
+ port: undefined,
63
+ socket: undefined,
64
+ timeout: 15000,
65
+ retries: 0,
66
+ json: false,
67
+ quiet: false,
68
+ delete: false,
69
+ recursive: true,
70
+ };
71
+
72
+ let i = 0;
73
+ while (i < args.length) {
74
+ const a = args[i];
75
+ if (a === 'scan' || a === 'watch' || a === 'version' || a === 'help') {
76
+ opts.command = a;
77
+ } else if (a === '--json') {
78
+ opts.json = true;
79
+ } else if (a === '--quiet' || a === '-q') {
80
+ opts.quiet = true;
81
+ } else if (a === '--recursive' || a === '-r') {
82
+ opts.recursive = true;
83
+ } else if (a === '--delete') {
84
+ opts.delete = true;
85
+ } else if (a === '--host') {
86
+ opts.host = args[++i];
87
+ } else if (a === '--port') {
88
+ opts.port = parseInt(args[++i], 10);
89
+ } else if (a === '--socket') {
90
+ opts.socket = args[++i];
91
+ } else if (a === '--timeout') {
92
+ opts.timeout = parseInt(args[++i], 10);
93
+ } else if (a === '--retries') {
94
+ opts.retries = parseInt(args[++i], 10);
95
+ } else if (!a.startsWith('-') && opts.command && !opts.target) {
96
+ opts.target = a;
97
+ }
98
+ i++;
99
+ }
100
+
101
+ return opts;
102
+ }
103
+
104
+ // ── Scan options builder ──────────────────────────────────────────────────────
105
+
106
+ function buildScanOpts(opts) {
107
+ const o = { timeout: opts.timeout, retries: opts.retries };
108
+ if (opts.host !== undefined) o.host = opts.host;
109
+ if (opts.port !== undefined) o.port = opts.port;
110
+ if (opts.socket !== undefined) o.socket = opts.socket;
111
+ return o;
112
+ }
113
+
114
+ // ── Progress bar ──────────────────────────────────────────────────────────────
115
+
116
+ function progressBar(done, total, infected) {
117
+ const pct = total === 0 ? 0 : Math.floor((done / total) * 100);
118
+ const filled = Math.floor(pct / 5);
119
+ const empty = 20 - filled;
120
+ const bar = '█'.repeat(filled) + '░'.repeat(empty);
121
+ return ` Scanning... [${bar}] ${pct}% • ${done}/${total} files • ${infected} infected`;
122
+ }
123
+
124
+ // ── Box drawing output ────────────────────────────────────────────────────────
125
+
126
+ function boxLine(content, width) {
127
+ const padded = content.padEnd(width - 4);
128
+ return `│ ${padded} │`;
129
+ }
130
+
131
+ function printResults(results, elapsed, opts) {
132
+ if (opts.json) {
133
+ const out = {
134
+ scanned: results.length,
135
+ infected: results.filter(r => r.verdict === 'infected').length,
136
+ errors: results.filter(r => r.verdict === 'error').length,
137
+ time: Math.round(elapsed / 100) / 10,
138
+ results: results.map(r => {
139
+ const o = { file: r.file, verdict: r.verdict };
140
+ if (r.viruses) o.viruses = r.viruses;
141
+ return o;
142
+ }),
143
+ };
144
+ console.log(JSON.stringify(out, null, 2));
145
+ return;
146
+ }
147
+
148
+ const W = 46;
149
+ const top = '┌' + '─'.repeat(W) + '┐';
150
+ const sep = '├' + '─'.repeat(W) + '┤';
151
+ const bottom = '└' + '─'.repeat(W) + '┘';
152
+
153
+ const infected = results.filter(r => r.verdict === 'infected').length;
154
+ const secs = (elapsed / 1000).toFixed(1);
155
+
156
+ console.log(top);
157
+ console.log(boxLine('🍊 pompelmi scan results', W));
158
+ console.log(sep);
159
+
160
+ for (const r of results) {
161
+ if (opts.quiet && r.verdict === 'clean') continue;
162
+ const short = r.file.length > 30 ? '...' + r.file.slice(-27) : r.file;
163
+ if (r.verdict === 'clean') {
164
+ console.log(boxLine(`\x1b[32m✅ CLEAN\x1b[0m ${short}`, W + 9));
165
+ } else if (r.verdict === 'infected') {
166
+ console.log(boxLine(`\x1b[31m🚨 INFECTED\x1b[0m ${short}`, W + 9));
167
+ if (r.viruses && r.viruses.length) {
168
+ const vname = r.viruses[0] || '';
169
+ console.log(boxLine(`\x1b[90m └─ ${vname}\x1b[0m`, W + 8));
170
+ }
171
+ } else {
172
+ console.log(boxLine(`\x1b[33m⚠️ ERROR\x1b[0m ${short}`, W + 12));
173
+ }
174
+ }
175
+
176
+ console.log(sep);
177
+ const summary = `Scanned: ${results.length} • Infected: ${infected} • Time: ${secs}s`;
178
+ console.log(boxLine(summary, W));
179
+ console.log(bottom);
180
+ }
181
+
182
+ // ── Scan a single file ────────────────────────────────────────────────────────
183
+
184
+ async function scanOne(filePath, scanOpts) {
185
+ const { scan } = require('../src/ClamAVScanner.js');
186
+ const { Verdict } = require('../src/verdicts.js');
187
+ try {
188
+ const v = await scan(filePath, scanOpts);
189
+ if (v === Verdict.Clean) return { file: filePath, verdict: 'clean' };
190
+ if (v === Verdict.Malicious) return { file: filePath, verdict: 'infected', viruses: [] };
191
+ return { file: filePath, verdict: 'error' };
192
+ } catch (err) {
193
+ if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|unreachable/i.test(err.message)) {
194
+ return { file: filePath, verdict: 'error', _clamdUnreachable: true };
195
+ }
196
+ return { file: filePath, verdict: 'error' };
197
+ }
198
+ }
199
+
200
+ // ── Collect files from path ───────────────────────────────────────────────────
201
+
202
+ function collectFiles(target) {
203
+ const stat = fs.statSync(target);
204
+ if (stat.isFile()) return [target];
205
+ const entries = fs.readdirSync(target, { recursive: true });
206
+ return entries
207
+ .map(e => path.join(target, e))
208
+ .filter(f => { try { return fs.statSync(f).isFile(); } catch { return false; } });
209
+ }
210
+
211
+ // ── Ask confirmation ──────────────────────────────────────────────────────────
212
+
213
+ function confirm(question) {
214
+ return new Promise(resolve => {
215
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
216
+ rl.question(question, ans => {
217
+ rl.close();
218
+ resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
219
+ });
220
+ });
221
+ }
222
+
223
+ // ── Commands ──────────────────────────────────────────────────────────────────
224
+
225
+ async function cmdScan(opts) {
226
+ if (!opts.target) {
227
+ console.error('Usage: pompelmi scan <file|dir> [options]');
228
+ process.exit(2);
229
+ }
230
+
231
+ const target = path.resolve(opts.target);
232
+ if (!fs.existsSync(target)) {
233
+ console.error(`Error: path not found: ${target}`);
234
+ process.exit(2);
235
+ }
236
+
237
+ if (!opts.json && !opts.quiet) await printLogo();
238
+
239
+ const files = collectFiles(target);
240
+ const scanOpts = buildScanOpts(opts);
241
+ const results = [];
242
+ const start = Date.now();
243
+ let infectedCount = 0;
244
+
245
+ for (let i = 0; i < files.length; i++) {
246
+ if (!opts.json && !opts.quiet && files.length > 1) {
247
+ process.stdout.write('\r' + progressBar(i, files.length, infectedCount));
248
+ }
249
+ const r = await scanOne(files[i], scanOpts);
250
+ if (r.verdict === 'infected') infectedCount++;
251
+ results.push(r);
252
+ }
253
+
254
+ if (!opts.json && !opts.quiet && files.length > 1) {
255
+ process.stdout.write('\r' + progressBar(files.length, files.length, infectedCount) + '\n');
256
+ }
257
+
258
+ const elapsed = Date.now() - start;
259
+
260
+ if (opts.delete) {
261
+ const infected = results.filter(r => r.verdict === 'infected');
262
+ if (infected.length > 0) {
263
+ if (!opts.json) {
264
+ console.log(`\nFound ${infected.length} infected file(s):`);
265
+ for (const r of infected) console.log(` ${r.file}`);
266
+ }
267
+ const ok = await confirm('Delete these files? [y/N] ');
268
+ if (ok) {
269
+ for (const r of infected) {
270
+ try { fs.unlinkSync(r.file); if (!opts.json) console.log(` Deleted: ${r.file}`); }
271
+ catch (e) { if (!opts.json) console.log(` Failed to delete: ${r.file}: ${e.message}`); }
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ printResults(results, elapsed, opts);
278
+
279
+ const hasInfected = results.some(r => r.verdict === 'infected');
280
+ const hasError = results.some(r => r.verdict === 'error');
281
+ const clamdDown = results.some(r => r._clamdUnreachable);
282
+
283
+ if (clamdDown) process.exit(3);
284
+ if (hasInfected) process.exit(1);
285
+ if (hasError) process.exit(2);
286
+ process.exit(0);
287
+ }
288
+
289
+ async function cmdWatch(opts) {
290
+ if (!opts.target) {
291
+ console.error('Usage: pompelmi watch <dir> [options]');
292
+ process.exit(2);
293
+ }
294
+
295
+ const target = path.resolve(opts.target);
296
+ if (!fs.existsSync(target)) {
297
+ console.error(`Error: directory not found: ${target}`);
298
+ process.exit(2);
299
+ }
300
+
301
+ if (!opts.json && !opts.quiet) await printLogo();
302
+
303
+ const { watch } = require('../src/Watcher.js');
304
+ const scanOpts = buildScanOpts(opts);
305
+
306
+ let scanned = 0, clean = 0, infected = 0;
307
+
308
+ function status() {
309
+ process.stdout.write(
310
+ `\rWatching ${target} • Scanned: ${scanned} • Clean: ${clean} • Infected: ${infected} `
311
+ );
312
+ }
313
+
314
+ status();
315
+
316
+ watch(target, scanOpts, {
317
+ onClean(fp) {
318
+ scanned++; clean++;
319
+ if (!opts.quiet) process.stdout.write(`\n\x1b[32m✅ CLEAN\x1b[0m ${fp}\n`);
320
+ status();
321
+ },
322
+ onMalicious(fp) {
323
+ scanned++; infected++;
324
+ process.stdout.write(`\n\x1b[31m🚨 INFECTED\x1b[0m ${fp}\n`);
325
+ status();
326
+ },
327
+ onError(err) {
328
+ scanned++;
329
+ if (!opts.quiet) process.stdout.write(`\n\x1b[33m⚠️ ERROR\x1b[0m ${err.message}\n`);
330
+ status();
331
+ },
332
+ });
333
+
334
+ process.on('SIGINT', () => { process.stdout.write('\n'); process.exit(0); });
335
+ }
336
+
337
+ function cmdVersion() {
338
+ console.log(pkg.version);
339
+ }
340
+
341
+ function cmdHelp() {
342
+ console.log(`
343
+ pompelmi v${pkg.version} — ClamAV Antivirus Scanning for Node.js
344
+
345
+ USAGE
346
+ pompelmi <command> [options]
347
+
348
+ COMMANDS
349
+ scan <file|dir> Scan a file or directory for viruses
350
+ watch <dir> Watch a directory and auto-scan new/modified files
351
+ version Print version number
352
+ help Show this help message
353
+
354
+ SCAN OPTIONS
355
+ --recursive, -r Scan directory recursively (default: true)
356
+ --host <host> clamd host (default: localhost, uses local clamscan if omitted)
357
+ --port <port> clamd port (default: 3310)
358
+ --socket <path> UNIX socket path (e.g. /run/clamav/clamd.sock)
359
+ --timeout <ms> Connection timeout in ms (default: 15000)
360
+ --retries <n> Auto-retry on connection failure (default: 0)
361
+ --json Output results as JSON (no logo, no colors)
362
+ --quiet, -q Only print infected files and summary
363
+ --delete Delete infected files after confirmation
364
+
365
+ WATCH OPTIONS
366
+ --host, --port, --socket, --timeout (same as scan)
367
+
368
+ EXIT CODES
369
+ 0 All files clean
370
+ 1 Virus found
371
+ 2 Scan error
372
+ 3 clamd unreachable
373
+
374
+ EXAMPLES
375
+ # Scan a single file (local clamscan)
376
+ pompelmi scan ./upload.pdf
377
+
378
+ # Scan a directory recursively
379
+ pompelmi scan ./uploads --recursive
380
+
381
+ # Scan via clamd over TCP
382
+ pompelmi scan ./uploads --host 127.0.0.1 --port 3310
383
+
384
+ # Scan via UNIX socket
385
+ pompelmi scan ./uploads --socket /run/clamav/clamd.sock
386
+
387
+ # JSON output for scripting
388
+ pompelmi scan ./uploads --json
389
+
390
+ # Watch a directory
391
+ pompelmi watch ./uploads --host 127.0.0.1 --port 3310
392
+
393
+ # Use with npx (no install required)
394
+ npx pompelmi scan ./uploads
395
+ `);
396
+ }
397
+
398
+ // ── Entry point ───────────────────────────────────────────────────────────────
399
+
400
+ async function main() {
401
+ const opts = parseArgs(process.argv);
402
+
403
+ if (!opts.command || opts.command === 'help') {
404
+ await printLogo();
405
+ cmdHelp();
406
+ process.exit(0);
407
+ }
408
+
409
+ if (opts.command === 'version') {
410
+ cmdVersion();
411
+ process.exit(0);
412
+ }
413
+
414
+ if (opts.command === 'scan') {
415
+ await cmdScan(opts);
416
+ return;
417
+ }
418
+
419
+ if (opts.command === 'watch') {
420
+ await cmdWatch(opts);
421
+ return;
422
+ }
423
+ }
424
+
425
+ main().catch(err => {
426
+ console.error('\x1b[31mError:\x1b[0m', err.message);
427
+ process.exit(2);
428
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
5
5
  "license": "ISC",
6
6
  "author": "pompelmi contributors",
@@ -33,6 +33,9 @@
33
33
  "type": "commonjs",
34
34
  "main": "./src/index.js",
35
35
  "types": "./types/index.d.ts",
36
+ "bin": {
37
+ "pompelmi": "./bin/pompelmi.js"
38
+ },
36
39
  "scripts": {
37
40
  "test": "node --test test/unit.test.js && node test/scan.test.js",
38
41
  "lint": "eslint src/"
@@ -40,7 +43,9 @@
40
43
  "publishConfig": {
41
44
  "registry": "https://registry.npmjs.org/"
42
45
  },
43
- "dependencies": {},
46
+ "dependencies": {
47
+ "terminal-image": "^4.3.0"
48
+ },
44
49
  "devDependencies": {
45
50
  "@eslint/js": "^10.0.1",
46
51
  "eslint": "^10.2.0",
package/pr_info.tmp ADDED
@@ -0,0 +1,2 @@
1
+ {"branch":null,"number":null,"url":null}
2
+ EOF