simple-dynamsoft-mcp 2.0.3 → 2.0.4

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.
@@ -0,0 +1,96 @@
1
+ from dynamsoft_barcode_reader_bundle import *
2
+ import cv2
3
+ import numpy as np
4
+ import queue
5
+ from utils import *
6
+
7
+
8
+ class FrameFetcher(ImageSourceAdapter):
9
+ def has_next_image_to_fetch(self) -> bool:
10
+ return True
11
+
12
+ def add_frame(self, imageData):
13
+ self.add_image_to_buffer(imageData)
14
+
15
+
16
+ class MyCapturedResultReceiver(CapturedResultReceiver):
17
+ def __init__(self, result_queue):
18
+ super().__init__()
19
+ self.result_queue = result_queue
20
+
21
+ def on_captured_result_received(self, captured_result):
22
+ self.result_queue.put(captured_result)
23
+
24
+
25
+ if __name__ == '__main__':
26
+ errorCode, errorMsg = LicenseManager.init_license(
27
+ "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==")
28
+ if errorCode != EnumErrorCode.EC_OK and errorCode != EnumErrorCode.EC_LICENSE_CACHE_USED:
29
+ print("License initialization failed: ErrorCode:",
30
+ errorCode, ", ErrorString:", errorMsg)
31
+ else:
32
+ vc = cv2.VideoCapture(0)
33
+ if not vc.isOpened():
34
+ print("Error: Camera is not opened!")
35
+ exit(1)
36
+
37
+ cvr = CaptureVisionRouter()
38
+ fetcher = FrameFetcher()
39
+ cvr.set_input(fetcher)
40
+
41
+ # Create a thread-safe queue to store captured items
42
+ result_queue = queue.Queue()
43
+
44
+ receiver = MyCapturedResultReceiver(result_queue)
45
+ cvr.add_result_receiver(receiver)
46
+
47
+ errorCode, errorMsg = cvr.start_capturing(
48
+ EnumPresetTemplate.PT_READ_BARCODES.value)
49
+
50
+ if errorCode != EnumErrorCode.EC_OK:
51
+ print("error:", errorMsg)
52
+
53
+ while True:
54
+ ret, frame = vc.read()
55
+ if not ret:
56
+ print("Error: Cannot read frame!")
57
+ break
58
+
59
+ fetcher.add_frame(convertMat2ImageData(frame))
60
+
61
+ if not result_queue.empty():
62
+ captured_result = result_queue.get_nowait()
63
+
64
+ items = captured_result.get_items()
65
+ for item in items:
66
+
67
+ if item.get_type() == EnumCapturedResultItemType.CRIT_BARCODE:
68
+ text = item.get_text()
69
+ location = item.get_location()
70
+ x1 = location.points[0].x
71
+ y1 = location.points[0].y
72
+ x2 = location.points[1].x
73
+ y2 = location.points[1].y
74
+ x3 = location.points[2].x
75
+ y3 = location.points[2].y
76
+ x4 = location.points[3].x
77
+ y4 = location.points[3].y
78
+ pts = np.array([(x1, y1), (x2, y2), (x3, y3), (x4, y4)], np.int32).reshape((-1, 1, 2))
79
+ cv2.drawContours(
80
+ frame, [pts], 0, (0, 255, 0), 2)
81
+
82
+ cv2.putText(frame, text, (x1, y1),
83
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
84
+
85
+ del location
86
+
87
+ if cv2.waitKey(1) & 0xFF == ord('q'):
88
+ break
89
+ elif cv2.waitKey(1) & 0xFF == ord('c'):
90
+ cv2.imshow('Captured Image', frame)
91
+
92
+ cv2.imshow('frame', frame)
93
+
94
+ cvr.stop_capturing()
95
+ vc.release()
96
+ cv2.destroyAllWindows()
@@ -0,0 +1,82 @@
1
+ import sys
2
+ from dynamsoft_barcode_reader_bundle import *
3
+ import os
4
+ import cv2
5
+ import numpy as np
6
+ from utils import *
7
+
8
+ if __name__ == '__main__':
9
+
10
+ print("**********************************************************")
11
+ print("Welcome to Dynamsoft Capture Vision - Barcode Sample")
12
+ print("**********************************************************")
13
+
14
+ error_code, error_message = LicenseManager.init_license(
15
+ "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==")
16
+ if error_code != EnumErrorCode.EC_OK and error_code != EnumErrorCode.EC_LICENSE_CACHE_USED:
17
+ print("License initialization failed: ErrorCode:",
18
+ error_code, ", ErrorString:", error_message)
19
+ else:
20
+ cvr_instance = CaptureVisionRouter()
21
+ while (True):
22
+ image_path = input(
23
+ ">> Input your image full path:\n"
24
+ ">> 'Enter' for sample image or 'Q'/'q' to quit\n"
25
+ ).strip('\'"')
26
+
27
+ if image_path.lower() == "q":
28
+ sys.exit(0)
29
+
30
+ if image_path == "":
31
+ image_path = "../../../images/multi.png"
32
+
33
+ if not os.path.exists(image_path):
34
+ print("The image path does not exist.")
35
+ continue
36
+
37
+ cv_image = cv2.imread(image_path)
38
+ result = cvr_instance.capture(
39
+ cv_image, EnumPresetTemplate.PT_READ_BARCODES.value)
40
+ if result.get_error_code() != EnumErrorCode.EC_OK:
41
+ print("Error:", result.get_error_code(),
42
+ result.get_error_string())
43
+ else:
44
+
45
+
46
+ items = result.get_items()
47
+ print('Found {} barcodes.'.format(len(items)))
48
+ for item in items:
49
+ format_type = item.get_format_string()
50
+ text = item.get_text()
51
+ print("Barcode Format:", format_type)
52
+ print("Barcode Text:", text)
53
+
54
+ location = item.get_location()
55
+ x1 = location.points[0].x
56
+ y1 = location.points[0].y
57
+ x2 = location.points[1].x
58
+ y2 = location.points[1].y
59
+ x3 = location.points[2].x
60
+ y3 = location.points[2].y
61
+ x4 = location.points[3].x
62
+ y4 = location.points[3].y
63
+ print("Location Points:")
64
+ print("({}, {})".format(x1, y1))
65
+ print("({}, {})".format(x2, y2))
66
+ print("({}, {})".format(x3, y3))
67
+ print("({}, {})".format(x4, y4))
68
+ print("-------------------------------------------------")
69
+
70
+ pts = np.array([(x1, y1), (x2, y2), (x3, y3), (x4, y4)], np.int32).reshape((-1, 1, 2))
71
+ cv2.drawContours(
72
+ cv_image, [pts], 0, (0, 255, 0), 2)
73
+
74
+ cv2.putText(cv_image, text, (x1, y1 - 10),
75
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2)
76
+
77
+ cv2.imshow(
78
+ "Original Image with Detected Barcodes", cv_image)
79
+ cv2.waitKey(0)
80
+ cv2.destroyAllWindows()
81
+
82
+ input("Press Enter to quit...")
@@ -0,0 +1,60 @@
1
+ from dynamsoft_barcode_reader_bundle import *
2
+ import numpy as np
3
+
4
+
5
+ def convertImageData2Mat(normalized_image):
6
+ ba = bytearray(normalized_image.get_bytes())
7
+ width = normalized_image.get_width()
8
+ height = normalized_image.get_height()
9
+
10
+ channels = 3
11
+ if normalized_image.get_image_pixel_format() == EnumImagePixelFormat.IPF_BINARY:
12
+ channels = 1
13
+ all = []
14
+ skip = normalized_image.stride * 8 - width
15
+
16
+ index = 0
17
+ n = 1
18
+ for byte in ba:
19
+
20
+ byteCount = 7
21
+ while byteCount >= 0:
22
+ b = (byte & (1 << byteCount)) >> byteCount
23
+
24
+ if index < normalized_image.stride * 8 * n - skip:
25
+ if b == 1:
26
+ all.append(255)
27
+ else:
28
+ all.append(0)
29
+
30
+ byteCount -= 1
31
+ index += 1
32
+
33
+ if index == normalized_image.stride * 8 * n:
34
+ n += 1
35
+
36
+ mat = np.array(all, dtype=np.uint8).reshape(height, width, channels)
37
+ return mat
38
+
39
+ elif normalized_image.get_image_pixel_format() == EnumImagePixelFormat.IPF_GRAYSCALED:
40
+ channels = 1
41
+
42
+ mat = np.array(ba, dtype=np.uint8).reshape(height, width, channels)
43
+
44
+ return mat
45
+
46
+
47
+ def convertMat2ImageData(mat):
48
+ if len(mat.shape) == 3:
49
+ height, width, channels = mat.shape
50
+ pixel_format = EnumImagePixelFormat.IPF_RGB_888
51
+ else:
52
+ height, width = mat.shape
53
+ channels = 1
54
+ pixel_format = EnumImagePixelFormat.IPF_GRAYSCALED
55
+
56
+ stride = width * channels
57
+ imagedata = ImageData(mat.tobytes(), width, height, stride, pixel_format)
58
+ return imagedata
59
+
60
+
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "simple-dynamsoft-mcp",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "MCP server for Dynamsoft SDKs - Barcode Reader (Mobile/Python/Web) and Dynamic Web TWAIN. Provides documentation, code snippets, and API guidance.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/yushulx/simple-dynamsoft-mcp.git"
8
+ },
5
9
  "type": "module",
6
10
  "engines": {
7
11
  "node": ">=18"
@@ -13,10 +17,12 @@
13
17
  "src",
14
18
  "data",
15
19
  "code-snippet",
20
+ "test",
16
21
  "README.md"
17
22
  ],
18
23
  "scripts": {
19
- "start": "node src/index.js"
24
+ "start": "node src/index.js",
25
+ "test": "node test/server.test.js"
20
26
  },
21
27
  "keywords": [
22
28
  "mcp",
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Automated tests for Dynamsoft MCP Server
5
+ * Run with: node test/server.test.js
6
+ */
7
+
8
+ import { spawn } from 'child_process';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const serverPath = join(__dirname, '..', 'src', 'index.js');
15
+
16
+ // Test counters
17
+ let passed = 0;
18
+ let failed = 0;
19
+ const results = [];
20
+
21
+ /**
22
+ * Send a JSON-RPC request to the server and get the response
23
+ */
24
+ async function sendRequest(request) {
25
+ return new Promise((resolve, reject) => {
26
+ const proc = spawn('node', [serverPath], {
27
+ stdio: ['pipe', 'pipe', 'pipe']
28
+ });
29
+
30
+ let stdout = '';
31
+ let stderr = '';
32
+
33
+ proc.stdout.on('data', (data) => {
34
+ stdout += data.toString();
35
+ });
36
+
37
+ proc.stderr.on('data', (data) => {
38
+ stderr += data.toString();
39
+ });
40
+
41
+ proc.on('close', (code) => {
42
+ try {
43
+ // Parse only the JSON-RPC response (last complete JSON object)
44
+ const lines = stdout.trim().split('\n');
45
+ const jsonLine = lines.find(line => {
46
+ try {
47
+ const parsed = JSON.parse(line);
48
+ return parsed.jsonrpc === '2.0';
49
+ } catch {
50
+ return false;
51
+ }
52
+ });
53
+
54
+ if (jsonLine) {
55
+ resolve(JSON.parse(jsonLine));
56
+ } else {
57
+ reject(new Error(`No valid JSON-RPC response. stdout: ${stdout}, stderr: ${stderr}`));
58
+ }
59
+ } catch (e) {
60
+ reject(new Error(`Failed to parse response: ${e.message}. stdout: ${stdout}`));
61
+ }
62
+ });
63
+
64
+ proc.on('error', reject);
65
+
66
+ // Send the request and close stdin
67
+ proc.stdin.write(JSON.stringify(request) + '\n');
68
+ proc.stdin.end();
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Run a test case
74
+ */
75
+ async function test(name, fn) {
76
+ try {
77
+ await fn();
78
+ passed++;
79
+ results.push({ name, status: 'āœ… PASSED' });
80
+ console.log(`āœ… ${name}`);
81
+ } catch (error) {
82
+ failed++;
83
+ results.push({ name, status: 'āŒ FAILED', error: error.message });
84
+ console.log(`āŒ ${name}`);
85
+ console.log(` Error: ${error.message}`);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Assert helper
91
+ */
92
+ function assert(condition, message) {
93
+ if (!condition) {
94
+ throw new Error(message || 'Assertion failed');
95
+ }
96
+ }
97
+
98
+ // ============================================
99
+ // Test Cases
100
+ // ============================================
101
+
102
+ console.log('\n🧪 Dynamsoft MCP Server Test Suite\n');
103
+ console.log('='.repeat(50));
104
+
105
+ // Test 1: Server initialization
106
+ await test('Server responds to initialize request', async () => {
107
+ const response = await sendRequest({
108
+ jsonrpc: '2.0',
109
+ id: 1,
110
+ method: 'initialize',
111
+ params: {
112
+ protocolVersion: '2024-11-05',
113
+ capabilities: {},
114
+ clientInfo: { name: 'test-client', version: '1.0.0' }
115
+ }
116
+ });
117
+
118
+ assert(response.result, 'Should have result');
119
+ assert(response.result.serverInfo, 'Should have serverInfo');
120
+ assert(response.result.serverInfo.name === 'simple-dynamsoft-mcp', 'Server name should match');
121
+ });
122
+
123
+ // Test 2: List tools
124
+ await test('tools/list returns all 13 tools', async () => {
125
+ const response = await sendRequest({
126
+ jsonrpc: '2.0',
127
+ id: 1,
128
+ method: 'tools/list'
129
+ });
130
+
131
+ assert(response.result, 'Should have result');
132
+ assert(response.result.tools, 'Should have tools array');
133
+ assert(response.result.tools.length === 13, `Expected 13 tools, got ${response.result.tools.length}`);
134
+
135
+ const toolNames = response.result.tools.map(t => t.name);
136
+ const expectedTools = [
137
+ 'list_sdks', 'get_sdk_info', 'list_samples', 'list_python_samples',
138
+ 'list_dwt_categories', 'get_code_snippet', 'get_python_sample',
139
+ 'get_dwt_sample', 'get_quick_start', 'get_gradle_config',
140
+ 'get_license_info', 'get_api_usage', 'search_samples'
141
+ ];
142
+
143
+ for (const expected of expectedTools) {
144
+ assert(toolNames.includes(expected), `Missing tool: ${expected}`);
145
+ }
146
+ });
147
+
148
+ // Test 3: list_sdks tool
149
+ await test('list_sdks returns SDK information', async () => {
150
+ const response = await sendRequest({
151
+ jsonrpc: '2.0',
152
+ id: 1,
153
+ method: 'tools/call',
154
+ params: {
155
+ name: 'list_sdks',
156
+ arguments: {}
157
+ }
158
+ });
159
+
160
+ assert(response.result, 'Should have result');
161
+ assert(response.result.content, 'Should have content');
162
+ assert(response.result.content.length > 0, 'Should have content items');
163
+
164
+ const text = response.result.content[0].text;
165
+ assert(text.includes('dbr-mobile'), 'Should include dbr-mobile');
166
+ assert(text.includes('dbr-python'), 'Should include dbr-python');
167
+ assert(text.includes('dbr-web'), 'Should include dbr-web');
168
+ assert(text.includes('dwt'), 'Should include dwt');
169
+ });
170
+
171
+ // Test 4: get_sdk_info tool
172
+ await test('get_sdk_info returns detailed SDK info', async () => {
173
+ const response = await sendRequest({
174
+ jsonrpc: '2.0',
175
+ id: 1,
176
+ method: 'tools/call',
177
+ params: {
178
+ name: 'get_sdk_info',
179
+ arguments: { sdk_id: 'dbr-mobile' }
180
+ }
181
+ });
182
+
183
+ assert(response.result, 'Should have result');
184
+ assert(response.result.content, 'Should have content');
185
+
186
+ const text = response.result.content[0].text;
187
+ assert(text.includes('Android') || text.includes('android'), 'Should include Android');
188
+ assert(text.includes('11.2.5000'), 'Should include version');
189
+ });
190
+
191
+ // Test 5: get_license_info tool (requires platform parameter)
192
+ await test('get_license_info returns trial license', async () => {
193
+ const response = await sendRequest({
194
+ jsonrpc: '2.0',
195
+ id: 1,
196
+ method: 'tools/call',
197
+ params: {
198
+ name: 'get_license_info',
199
+ arguments: { platform: 'android' }
200
+ }
201
+ });
202
+
203
+ assert(response.result, 'Should have result');
204
+ assert(!response.result.isError, 'Should not be an error');
205
+
206
+ const text = response.result.content[0].text;
207
+ assert(text.includes('DLS2') || text.includes('License'), 'Should include license info');
208
+ });
209
+
210
+ // Test 6: list_samples tool
211
+ await test('list_samples returns mobile samples', async () => {
212
+ const response = await sendRequest({
213
+ jsonrpc: '2.0',
214
+ id: 1,
215
+ method: 'tools/call',
216
+ params: {
217
+ name: 'list_samples',
218
+ arguments: { platform: 'android' }
219
+ }
220
+ });
221
+
222
+ assert(response.result, 'Should have result');
223
+
224
+ const text = response.result.content[0].text;
225
+ assert(text.includes('android'), 'Should include android');
226
+ });
227
+
228
+ // Test 7: list_python_samples tool
229
+ await test('list_python_samples returns Python samples', async () => {
230
+ const response = await sendRequest({
231
+ jsonrpc: '2.0',
232
+ id: 1,
233
+ method: 'tools/call',
234
+ params: {
235
+ name: 'list_python_samples',
236
+ arguments: {}
237
+ }
238
+ });
239
+
240
+ assert(response.result, 'Should have result');
241
+ // Should return samples or indicate no local samples
242
+ assert(response.result.content, 'Should have content');
243
+ });
244
+
245
+ // Test 8: list_dwt_categories tool
246
+ await test('list_dwt_categories returns DWT categories', async () => {
247
+ const response = await sendRequest({
248
+ jsonrpc: '2.0',
249
+ id: 1,
250
+ method: 'tools/call',
251
+ params: {
252
+ name: 'list_dwt_categories',
253
+ arguments: {}
254
+ }
255
+ });
256
+
257
+ assert(response.result, 'Should have result');
258
+ // Should return categories or indicate they exist
259
+ assert(response.result.content, 'Should have content');
260
+ });
261
+
262
+ // Test 9: get_quick_start tool
263
+ await test('get_quick_start returns quick start guide', async () => {
264
+ const response = await sendRequest({
265
+ jsonrpc: '2.0',
266
+ id: 1,
267
+ method: 'tools/call',
268
+ params: {
269
+ name: 'get_quick_start',
270
+ arguments: { sdk_id: 'dbr-mobile', platform: 'android' }
271
+ }
272
+ });
273
+
274
+ assert(response.result, 'Should have result');
275
+
276
+ const text = response.result.content[0].text;
277
+ assert(text.includes('Quick Start') || text.includes('Android'), 'Should include quick start info');
278
+ });
279
+
280
+ // Test 10: get_gradle_config tool
281
+ await test('get_gradle_config returns Gradle configuration', async () => {
282
+ const response = await sendRequest({
283
+ jsonrpc: '2.0',
284
+ id: 1,
285
+ method: 'tools/call',
286
+ params: {
287
+ name: 'get_gradle_config',
288
+ arguments: {}
289
+ }
290
+ });
291
+
292
+ assert(response.result, 'Should have result');
293
+
294
+ const text = response.result.content[0].text;
295
+ assert(text.includes('gradle') || text.includes('Gradle') || text.includes('implementation'),
296
+ 'Should include Gradle config');
297
+ });
298
+
299
+ // Test 11: get_api_usage tool
300
+ await test('get_api_usage returns API usage info', async () => {
301
+ const response = await sendRequest({
302
+ jsonrpc: '2.0',
303
+ id: 1,
304
+ method: 'tools/call',
305
+ params: {
306
+ name: 'get_api_usage',
307
+ arguments: { sdk_id: 'dbr-mobile', api_name: 'decode' }
308
+ }
309
+ });
310
+
311
+ assert(response.result, 'Should have result');
312
+ assert(response.result.content, 'Should have content');
313
+ });
314
+
315
+ // Test 12: search_samples tool
316
+ await test('search_samples finds samples by keyword', async () => {
317
+ const response = await sendRequest({
318
+ jsonrpc: '2.0',
319
+ id: 1,
320
+ method: 'tools/call',
321
+ params: {
322
+ name: 'search_samples',
323
+ arguments: { query: 'barcode' }
324
+ }
325
+ });
326
+
327
+ assert(response.result, 'Should have result');
328
+ assert(response.result.content, 'Should have content');
329
+ });
330
+
331
+ // Test 13: resources/list returns resources
332
+ await test('resources/list returns registered resources', async () => {
333
+ const response = await sendRequest({
334
+ jsonrpc: '2.0',
335
+ id: 1,
336
+ method: 'resources/list'
337
+ });
338
+
339
+ assert(response.result, 'Should have result');
340
+ assert(response.result.resources, 'Should have resources array');
341
+ assert(response.result.resources.length > 0, 'Should have at least one resource');
342
+
343
+ // Check for expected resource types
344
+ const uris = response.result.resources.map(r => r.uri);
345
+ assert(uris.some(u => u.includes('sdk-info')), 'Should have sdk-info resources');
346
+ });
347
+
348
+ // Test 14: Invalid tool call returns error
349
+ await test('Invalid tool call returns proper error', async () => {
350
+ const response = await sendRequest({
351
+ jsonrpc: '2.0',
352
+ id: 1,
353
+ method: 'tools/call',
354
+ params: {
355
+ name: 'nonexistent_tool',
356
+ arguments: {}
357
+ }
358
+ });
359
+
360
+ assert(response.error || (response.result && response.result.isError),
361
+ 'Should return error for invalid tool');
362
+ });
363
+
364
+ // Test 15: Tool with invalid arguments returns error
365
+ await test('Tool with missing required arguments returns error', async () => {
366
+ const response = await sendRequest({
367
+ jsonrpc: '2.0',
368
+ id: 1,
369
+ method: 'tools/call',
370
+ params: {
371
+ name: 'get_license_info',
372
+ arguments: {} // Missing required platform
373
+ }
374
+ });
375
+
376
+ assert(response.result && response.result.isError,
377
+ 'Should return error for missing required argument');
378
+ });
379
+
380
+ // ============================================
381
+ // Test Summary
382
+ // ============================================
383
+
384
+ console.log('\n' + '='.repeat(50));
385
+ console.log('\nšŸ“Š Test Summary\n');
386
+ console.log(` Total: ${passed + failed}`);
387
+ console.log(` Passed: ${passed} āœ…`);
388
+ console.log(` Failed: ${failed} āŒ`);
389
+ console.log(` Rate: ${((passed / (passed + failed)) * 100).toFixed(1)}%`);
390
+
391
+ if (failed > 0) {
392
+ console.log('\nāŒ Failed Tests:');
393
+ results.filter(r => r.status.includes('FAILED')).forEach(r => {
394
+ console.log(` - ${r.name}: ${r.error}`);
395
+ });
396
+ }
397
+
398
+ console.log('\n' + '='.repeat(50));
399
+
400
+ // Exit with appropriate code
401
+ process.exit(failed > 0 ? 1 : 0);