pompelmi 1.3.0 → 1.5.0
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 +111 -5
- package/package.json +4 -1
- package/src/ClamAVScanner.js +80 -5
- package/src/StreamScanner.js +67 -0
- package/src/index.js +3 -3
- package/.claude/settings.local.json +0 -52
package/README.md
CHANGED
|
@@ -7,13 +7,31 @@
|
|
|
7
7
|
<p align="center"><strong>ClamAV antivirus scanning for Node.js — clean, typed, zero dependencies.</strong></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<
|
|
11
|
-
<
|
|
12
|
-
<a href="https://github.com/pompelmi/pompelmi"><img src="https://img.shields.io/github/stars/pompelmi/pompelmi?style=social" alt="GitHub stars"></a>
|
|
13
|
-
<img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker available">
|
|
10
|
+
<img src="https://img.shields.io/npm/v/pompelmi.svg" alt="npm version">
|
|
11
|
+
<img src="https://img.shields.io/npm/dw/pompelmi" alt="npm downloads">
|
|
14
12
|
<img src="https://img.shields.io/badge/license-ISC-blue.svg" alt="license">
|
|
15
|
-
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="
|
|
13
|
+
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="dependencies">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
16
17
|
<img src="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml/badge.svg" alt="Node.js CI">
|
|
18
|
+
<img src="https://github.com/pompelmi/pompelmi/actions/workflows/release.yml/badge.svg" alt="npm publish">
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://img.shields.io/badge/express-available-black?logo=express" alt="Express">
|
|
23
|
+
<img src="https://img.shields.io/badge/fastify-available-black?logo=fastify" alt="Fastify">
|
|
24
|
+
<img src="https://img.shields.io/badge/nestjs-available-red?logo=nestjs" alt="NestJS">
|
|
25
|
+
<img src="https://img.shields.io/badge/koa-available-lightgrey?logo=node.js" alt="Koa">
|
|
26
|
+
<img src="https://img.shields.io/badge/hono-available-orange" alt="Hono">
|
|
27
|
+
<img src="https://img.shields.io/badge/next.js-available-black?logo=next.js" alt="Next.js">
|
|
28
|
+
<img src="https://img.shields.io/badge/sveltekit-available-red?logo=svelte" alt="SvelteKit">
|
|
29
|
+
<img src="https://img.shields.io/badge/remix-available-black?logo=remix" alt="Remix">
|
|
30
|
+
<img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker">
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<img src="https://img.shields.io/github/stars/pompelmi/pompelmi?style=social" alt="GitHub stars">
|
|
17
35
|
</p>
|
|
18
36
|
|
|
19
37
|
---
|
|
@@ -43,6 +61,8 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
43
61
|
|
|
44
62
|
- Single `scan(filePath, [options])` function — works locally or against a remote clamd instance
|
|
45
63
|
- `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
|
|
64
|
+
- `scanStream(stream, [options])` — scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.
|
|
65
|
+
- `scanDirectory(dirPath, [options])` — recursively scan every file in a directory, returns clean/malicious/errors arrays
|
|
46
66
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
47
67
|
- Full TCP/clamd support via the INSTREAM protocol with configurable host, port, and timeout
|
|
48
68
|
- Built-in helpers to install ClamAV and update virus definitions programmatically
|
|
@@ -214,6 +234,22 @@ const files = ['/uploads/a.pdf', '/uploads/b.zip', '/uploads/c.png'];
|
|
|
214
234
|
const results = await Promise.all(files.map((f) => scan(f)));
|
|
215
235
|
```
|
|
216
236
|
|
|
237
|
+
### Scan a Directory
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
const fs = require('fs');
|
|
241
|
+
const { scanDirectory } = require('pompelmi');
|
|
242
|
+
|
|
243
|
+
const results = await scanDirectory('/uploads');
|
|
244
|
+
|
|
245
|
+
console.log('Clean:', results.clean);
|
|
246
|
+
console.log('Malicious:', results.malicious);
|
|
247
|
+
console.log('Errors:', results.errors);
|
|
248
|
+
|
|
249
|
+
// Delete all malicious files
|
|
250
|
+
results.malicious.forEach(f => fs.unlinkSync(f));
|
|
251
|
+
```
|
|
252
|
+
|
|
217
253
|
### Scan a Buffer
|
|
218
254
|
|
|
219
255
|
```js
|
|
@@ -226,6 +262,20 @@ if (result === Verdict.Malicious) throw new Error('Malware detected.');
|
|
|
226
262
|
if (result === Verdict.ScanError) console.warn('Scan incomplete.');
|
|
227
263
|
```
|
|
228
264
|
|
|
265
|
+
### Scan a Stream
|
|
266
|
+
|
|
267
|
+
```js
|
|
268
|
+
const { scanStream, Verdict } = require('pompelmi');
|
|
269
|
+
const { Readable } = require('stream');
|
|
270
|
+
|
|
271
|
+
// Useful for S3 getObject, HTTP downloads, or any piped source
|
|
272
|
+
const stream = s3.getObject({ Bucket, Key }).createReadStream();
|
|
273
|
+
const result = await scanStream(stream);
|
|
274
|
+
|
|
275
|
+
if (result === Verdict.Malicious) throw new Error('Malware detected.');
|
|
276
|
+
if (result === Verdict.ScanError) console.warn('Scan incomplete.');
|
|
277
|
+
```
|
|
278
|
+
|
|
229
279
|
---
|
|
230
280
|
|
|
231
281
|
## Docker / Remote Scanning
|
|
@@ -325,6 +375,61 @@ In TCP mode (`host` or `port` provided), the buffer is streamed directly to clam
|
|
|
325
375
|
|
|
326
376
|
---
|
|
327
377
|
|
|
378
|
+
### `scanStream(stream, [options])`
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
scanStream(
|
|
382
|
+
stream: Readable,
|
|
383
|
+
options?: { host?: string; port?: number; timeout?: number }
|
|
384
|
+
): Promise<symbol>
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
| Parameter | Type | Description |
|
|
388
|
+
|---|---|---|
|
|
389
|
+
| `stream` | `Readable` | Node.js Readable stream to scan |
|
|
390
|
+
| `options` | `object` | Same options as `scan()` — host, port, timeout |
|
|
391
|
+
|
|
392
|
+
**Returns** the same three Symbol verdicts as `scan()`: `Verdict.Clean`, `Verdict.Malicious`, `Verdict.ScanError`.
|
|
393
|
+
|
|
394
|
+
**Rejects** with the same error types as `scan()` where applicable, plus:
|
|
395
|
+
|
|
396
|
+
| Condition | Error message |
|
|
397
|
+
|---|---|
|
|
398
|
+
| `stream` is not a Readable | `stream must be a Readable` |
|
|
399
|
+
| Stream emits error | propagated as-is |
|
|
400
|
+
|
|
401
|
+
In TCP mode (`host` or `port` provided), the stream is piped directly to clamd via the INSTREAM protocol — no data is written to disk. In local mode, the stream is piped to a temp file in `os.tmpdir()` that is deleted automatically in a `finally` block.
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
### `scanDirectory(dirPath, [options])`
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
scanDirectory(
|
|
409
|
+
dirPath: string,
|
|
410
|
+
options?: { host?: string; port?: number; timeout?: number }
|
|
411
|
+
): Promise<{ clean: string[], malicious: string[], errors: string[] }>
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Recursively scans every file in `dirPath` and returns three arrays of absolute paths.
|
|
415
|
+
|
|
416
|
+
| Field | Type | Description |
|
|
417
|
+
|---|---|---|
|
|
418
|
+
| `clean` | `string[]` | Paths of files with no threats found |
|
|
419
|
+
| `malicious` | `string[]` | Paths of files with a matched signature |
|
|
420
|
+
| `errors` | `string[]` | Paths of files that could not be scanned |
|
|
421
|
+
|
|
422
|
+
Per-file scan failures are caught and collected into `errors` — the function never throws because of an individual file.
|
|
423
|
+
|
|
424
|
+
**Rejects** with an `Error` in these cases:
|
|
425
|
+
|
|
426
|
+
| Condition | Error message |
|
|
427
|
+
|---|---|
|
|
428
|
+
| `dirPath` is not a string | `dirPath must be a string` |
|
|
429
|
+
| Directory does not exist | `Directory not found: <path>` |
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
328
433
|
### `ClamAVInstaller()` _(internal)_
|
|
329
434
|
|
|
330
435
|
Installs ClamAV using the platform's native package manager. Resolves immediately if ClamAV is already installed.
|
|
@@ -388,6 +493,7 @@ The [`examples/`](./examples/) directory contains standalone runnable scripts. E
|
|
|
388
493
|
| [`scan-multiple-files.js`](examples/scan-multiple-files.js) | Concurrent scans with `Promise.all` |
|
|
389
494
|
| [`scan-directory.js`](examples/scan-directory.js) | Recursively scan every file in a directory |
|
|
390
495
|
| [`scan-buffer.js`](examples/scan-buffer.js) | Scan an in-memory Buffer via a temp-file shim |
|
|
496
|
+
| [`scan-stream.js`](examples/scan-stream.js) | Scan an S3 getObject Readable stream with scanStream() |
|
|
391
497
|
| [`rest-api-server.js`](examples/rest-api-server.js) | Minimal HTTP server exposing `POST /scan` |
|
|
392
498
|
| [`s3-scan-before-upload.js`](examples/s3-scan-before-upload.js) | Scan locally, then upload to S3 only if clean |
|
|
393
499
|
| [`cli-scan.js`](examples/cli-scan.js) | CLI tool: scan file paths, exit non-zero on threats |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pompelmi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"test": "node --test test/unit.test.js && node test/scan.test.js",
|
|
37
37
|
"lint": "eslint src/"
|
|
38
38
|
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"registry": "https://registry.npmjs.org/"
|
|
41
|
+
},
|
|
39
42
|
"dependencies": {},
|
|
40
43
|
"devDependencies": {
|
|
41
44
|
"@eslint/js": "^10.0.1",
|
package/src/ClamAVScanner.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
const { nativeSpawn: spawn }
|
|
1
|
+
const { nativeSpawn: spawn } = require('./spawn.js');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const {
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
5
|
+
const { Readable } = require('stream');
|
|
6
|
+
const { SCAN_RESULTS } = require('./config.js');
|
|
7
|
+
const { Verdict } = require('./verdicts.js');
|
|
8
|
+
const { scanViaClamd } = require('./ClamdScanner.js');
|
|
9
|
+
const { scanBufferViaClamd } = require('./BufferScanner.js');
|
|
10
|
+
const { scanStreamViaClamd } = require('./StreamScanner.js');
|
|
8
11
|
|
|
9
12
|
const MESSAGES = {
|
|
10
13
|
FILE_NOT_FOUND: (filePath) => `File not found: ${filePath}`,
|
|
@@ -62,4 +65,76 @@ async function scanBuffer(buffer, options = {}) {
|
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
async function scanStream(stream, options = {}) {
|
|
69
|
+
if (!(stream instanceof Readable)) {
|
|
70
|
+
throw new Error('stream must be a Readable');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (options.host !== undefined || options.port !== undefined) {
|
|
74
|
+
return scanStreamViaClamd(stream, options);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tmpPath = path.join(
|
|
78
|
+
os.tmpdir(),
|
|
79
|
+
`pompelmi-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
let settled = false;
|
|
84
|
+
function settle(err) {
|
|
85
|
+
if (settled) return;
|
|
86
|
+
settled = true;
|
|
87
|
+
if (err) reject(err);
|
|
88
|
+
else resolve();
|
|
89
|
+
}
|
|
90
|
+
const writable = fs.createWriteStream(tmpPath);
|
|
91
|
+
stream.on('error', settle);
|
|
92
|
+
writable.on('error', settle);
|
|
93
|
+
writable.on('finish', () => settle(null));
|
|
94
|
+
stream.pipe(writable);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return await scan(tmpPath);
|
|
99
|
+
} finally {
|
|
100
|
+
fs.unlink(tmpPath, () => {});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function scanDirectory(dirPath, options = {}) {
|
|
105
|
+
if (typeof dirPath !== 'string') {
|
|
106
|
+
throw new Error('dirPath must be a string');
|
|
107
|
+
}
|
|
108
|
+
if (!fs.existsSync(dirPath)) {
|
|
109
|
+
throw new Error(`Directory not found: ${dirPath}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const entries = fs.readdirSync(dirPath, { recursive: true });
|
|
113
|
+
const files = entries
|
|
114
|
+
.map(entry => path.join(dirPath, entry))
|
|
115
|
+
.filter(fullPath => fs.statSync(fullPath).isFile());
|
|
116
|
+
|
|
117
|
+
const results = await Promise.all(
|
|
118
|
+
files.map(async (filePath) => {
|
|
119
|
+
try {
|
|
120
|
+
return { filePath, verdict: await scan(filePath, options) };
|
|
121
|
+
} catch {
|
|
122
|
+
return { filePath, verdict: null };
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const clean = [];
|
|
128
|
+
const malicious = [];
|
|
129
|
+
const errors = [];
|
|
130
|
+
|
|
131
|
+
for (const { filePath, verdict } of results) {
|
|
132
|
+
if (verdict === Verdict.Clean) clean.push(filePath);
|
|
133
|
+
else if (verdict === Verdict.Malicious) malicious.push(filePath);
|
|
134
|
+
else errors.push(filePath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { clean, malicious, errors };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { scan, scanBuffer, scanStream, scanDirectory };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { Verdict } = require('./verdicts.js');
|
|
5
|
+
|
|
6
|
+
const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
|
|
7
|
+
|
|
8
|
+
function parseClamdResponse(raw) {
|
|
9
|
+
const text = raw.toString('utf8').trim();
|
|
10
|
+
if (text === 'stream: OK') return Verdict.Clean;
|
|
11
|
+
if (text.endsWith(' FOUND')) return Verdict.Malicious;
|
|
12
|
+
return Verdict.ScanError;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scan a Readable stream by piping it to a running clamd instance over TCP.
|
|
17
|
+
* No data is written to disk.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('stream').Readable} stream
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {string} [options.host='127.0.0.1']
|
|
22
|
+
* @param {number} [options.port=3310]
|
|
23
|
+
* @param {number} [options.timeout=15000]
|
|
24
|
+
* @returns {Promise<symbol>}
|
|
25
|
+
*/
|
|
26
|
+
function scanStreamViaClamd(stream, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const socket = net.createConnection({ host, port });
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let settled = false;
|
|
31
|
+
|
|
32
|
+
function settle(fn, value) {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
settled = true;
|
|
35
|
+
socket.destroy();
|
|
36
|
+
fn(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
socket.setTimeout(timeout);
|
|
40
|
+
socket.on('timeout', () =>
|
|
41
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
42
|
+
);
|
|
43
|
+
socket.on('error', (err) => settle(reject, err));
|
|
44
|
+
socket.on('data', (chunk) => chunks.push(chunk));
|
|
45
|
+
socket.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
46
|
+
|
|
47
|
+
socket.on('connect', () => {
|
|
48
|
+
socket.write(CLAMD_INSTREAM);
|
|
49
|
+
|
|
50
|
+
stream.on('error', (err) => settle(reject, err));
|
|
51
|
+
|
|
52
|
+
stream.on('data', (chunk) => {
|
|
53
|
+
const header = Buffer.allocUnsafe(4);
|
|
54
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
55
|
+
socket.write(header);
|
|
56
|
+
socket.write(chunk);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
stream.on('end', () => {
|
|
60
|
+
socket.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
61
|
+
socket.end();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { scanStreamViaClamd };
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { scan, scanBuffer } = require('./ClamAVScanner.js');
|
|
2
|
-
const { Verdict }
|
|
1
|
+
const { scan, scanBuffer, scanStream, scanDirectory } = require('./ClamAVScanner.js');
|
|
2
|
+
const { Verdict } = require('./verdicts.js');
|
|
3
3
|
|
|
4
|
-
module.exports = { scan, scanBuffer, Verdict };
|
|
4
|
+
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict };
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(node:*)",
|
|
5
|
-
"Bash(echo \"EXIT:$?\")",
|
|
6
|
-
"Bash(echo \"EXIT_CODE:$?\")",
|
|
7
|
-
"Bash(tee /tmp/pompelmi_test_out.txt)",
|
|
8
|
-
"Bash(echo \"done: $?\")",
|
|
9
|
-
"Bash(ls /Users/tommy/pompelmi/pompelmi/*.md)",
|
|
10
|
-
"Bash(ls /Users/tommy/pompelmi/pompelmi/LICENSE*)",
|
|
11
|
-
"WebSearch",
|
|
12
|
-
"WebFetch(domain:pompelmi.app)",
|
|
13
|
-
"WebFetch(domain:news.ycombinator.com)",
|
|
14
|
-
"WebFetch(domain:dev.to)",
|
|
15
|
-
"WebFetch(domain:socket.dev)",
|
|
16
|
-
"WebFetch(domain:helpnetsecurity.com)",
|
|
17
|
-
"WebFetch(domain:nodeweekly.com)",
|
|
18
|
-
"WebFetch(domain:bytes.dev)",
|
|
19
|
-
"WebFetch(domain:www.helpnetsecurity.com)",
|
|
20
|
-
"WebFetch(domain:www.enveil.com)",
|
|
21
|
-
"WebFetch(domain:img.helpnetsecurity.com)",
|
|
22
|
-
"WebFetch(domain:logo.clearbit.com)",
|
|
23
|
-
"WebFetch(domain:cdn.brandfetch.io)",
|
|
24
|
-
"WebFetch(domain:cooperpress.com)",
|
|
25
|
-
"WebFetch(domain:wirexsystems.com)",
|
|
26
|
-
"WebFetch(domain:www.zlti.com)",
|
|
27
|
-
"Bash(gh run:*)",
|
|
28
|
-
"Bash(gh api:*)",
|
|
29
|
-
"WebFetch(domain:api.github.com)",
|
|
30
|
-
"WebFetch(domain:raw.githubusercontent.com)",
|
|
31
|
-
"Bash(git -C /Users/tommy/pompelmi/pompelmi status)",
|
|
32
|
-
"Bash(git -C /Users/tommy/pompelmi/pompelmi log --oneline -5)",
|
|
33
|
-
"Bash(git pull:*)",
|
|
34
|
-
"Bash(git add:*)",
|
|
35
|
-
"Bash(git commit -m ':*)",
|
|
36
|
-
"Bash(git push:*)",
|
|
37
|
-
"Bash(grep -E '\\\\.\\(png|svg|ico|webp\\)$')",
|
|
38
|
-
"Bash(ls -d /Users/tommy/pompelmi/pompelmi/*/)",
|
|
39
|
-
"Bash(node --test test/unit.test.js)",
|
|
40
|
-
"Bash(echo \"exit:$?\")",
|
|
41
|
-
"Read(//tmp/**)",
|
|
42
|
-
"Bash(tee /Users/tommy/pompelmi/pompelmi/test_out.txt)",
|
|
43
|
-
"Bash(npm install:*)",
|
|
44
|
-
"Bash(npm ls:*)",
|
|
45
|
-
"Bash(wait)",
|
|
46
|
-
"Bash(git rebase *)",
|
|
47
|
-
"Bash(python3 *)",
|
|
48
|
-
"Bash(git *)",
|
|
49
|
-
"Bash(npm test *)"
|
|
50
|
-
]
|
|
51
|
-
}
|
|
52
|
-
}
|