newrelic 11.19.0 → 11.20.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/NEWS.md +34 -0
- package/api.js +10 -10
- package/lib/config/attribute-filter.js +1 -1
- package/lib/config/default.js +3 -3
- package/lib/instrumentation/aws-sdk/util.js +1 -1
- package/lib/llm-events/aws-bedrock/bedrock-command.js +12 -2
- package/lib/llm-events/aws-bedrock/bedrock-response.js +12 -1
- package/lib/llm-events/aws-bedrock/stream-handler.js +28 -0
- package/lib/shim/datastore-shim.js +2 -2
- package/lib/transaction/name-state.js +1 -1
- package/lib/util/hashes.js +3 -2
- package/lib/utilization/docker-info.js +1 -1
- package/lib/utilization/gcp-info.js +4 -2
- package/package.json +2 -2
package/NEWS.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
### v11.20.0 (2024-06-24)
|
|
2
|
+
|
|
3
|
+
#### Features
|
|
4
|
+
|
|
5
|
+
* Added support for Anthropic Claude 3 messages API ([#2278](https://github.com/newrelic/node-newrelic/pull/2278)) ([7e3cab9](https://github.com/newrelic/node-newrelic/commit/7e3cab9739a9924b904ce78380fd70b1e30cd89f))
|
|
6
|
+
|
|
7
|
+
#### Code refactoring
|
|
8
|
+
|
|
9
|
+
* Replaced instances of `substr()` with `substring()` ([#2274](https://github.com/newrelic/node-newrelic/pull/2274)) ([8f96c73](https://github.com/newrelic/node-newrelic/commit/8f96c734862d42f459dd4de8ed9d498ef7d693de))
|
|
10
|
+
|
|
11
|
+
#### Documentation
|
|
12
|
+
|
|
13
|
+
* Fixed typos in API jsdoc ([#2287](https://github.com/newrelic/node-newrelic/pull/2287)) ([7b3c8d1](https://github.com/newrelic/node-newrelic/commit/7b3c8d1445ea9876b36eda31d979dac9b65dad14))
|
|
14
|
+
* Updated compatibility report ([#2285](https://github.com/newrelic/node-newrelic/pull/2285)) ([b1b5e3e](https://github.com/newrelic/node-newrelic/commit/b1b5e3e54074cc8b535927f4edad07925618260c))
|
|
15
|
+
|
|
16
|
+
#### Miscellaneous chores
|
|
17
|
+
|
|
18
|
+
* Added a missing library we instrument to be picked up by the compatibility report ([#2261](https://github.com/newrelic/node-newrelic/pull/2261)) ([6c964b2](https://github.com/newrelic/node-newrelic/commit/6c964b2029874353908486228113a468959b1597))
|
|
19
|
+
* Added a script that generates Dashboard json for reporting on libraries by version ([#2267](https://github.com/newrelic/node-newrelic/pull/2267)) ([d2877c1](https://github.com/newrelic/node-newrelic/commit/d2877c17c7c2f4432b2ce4056c94200a2e7334b2))
|
|
20
|
+
* Added AI support docs for automation ([#2249](https://github.com/newrelic/node-newrelic/pull/2249)) ([e2efc1f](https://github.com/newrelic/node-newrelic/commit/e2efc1fb202a3e87be710f352546a00df6bf6cef))
|
|
21
|
+
* Fixed comment about transaction_tracer.transaction_threshold ([#2258](https://github.com/newrelic/node-newrelic/pull/2258)) ([7cd6aef](https://github.com/newrelic/node-newrelic/commit/7cd6aef7ecc32d8647f0313b36be368bfa14a0e9))
|
|
22
|
+
* Made elastic versioned tests work on Node 16 ([#2284](https://github.com/newrelic/node-newrelic/pull/2284)) ([143b475](https://github.com/newrelic/node-newrelic/commit/143b4757c27d497a25d1b3c7fd56b32bcede619f))
|
|
23
|
+
* Updated docker compose configuration ([#2268](https://github.com/newrelic/node-newrelic/pull/2268)) ([eb1cce9](https://github.com/newrelic/node-newrelic/commit/eb1cce9143fe87eed1bdd077471942f85cf14f21))
|
|
24
|
+
* Updated GitHub Actions versions ([#2272](https://github.com/newrelic/node-newrelic/pull/2272)) ([07a841b](https://github.com/newrelic/node-newrelic/commit/07a841b9acf0a0dcb1a495c5cb0ee8f77e283fc9))
|
|
25
|
+
* Updated mysql tests to use MySQL 8.3 ([#2280](https://github.com/newrelic/node-newrelic/pull/2280)) ([11e0f8c](https://github.com/newrelic/node-newrelic/commit/11e0f8cf63a5f792419382635496a29c0286c737))
|
|
26
|
+
* Updated mysql2 tests to fix CI issue ([#2282](https://github.com/newrelic/node-newrelic/pull/2282)) ([5a37971](https://github.com/newrelic/node-newrelic/commit/5a37971b03fe8d69b39db66d97ecfc1176c42902))
|
|
27
|
+
* Updated versioned security agent workflow with simpler setup ([#2259](https://github.com/newrelic/node-newrelic/pull/2259)) ([a886187](https://github.com/newrelic/node-newrelic/commit/a886187bf1bf29d2430e38223d0215436e006540))
|
|
28
|
+
|
|
29
|
+
#### Continuous integration
|
|
30
|
+
|
|
31
|
+
* Added ability to run a build step in create release ([#2273](https://github.com/newrelic/node-newrelic/pull/2273)) ([4c2f24c](https://github.com/newrelic/node-newrelic/commit/4c2f24cb6e94dd05f269998eeb763196c3fac274))
|
|
32
|
+
* Updated security agent workflow to use large runners when available ([#2271](https://github.com/newrelic/node-newrelic/pull/2271)) ([181ec1b](https://github.com/newrelic/node-newrelic/commit/181ec1bfa8d420514fca3d6de6c3989e796a29db))
|
|
33
|
+
* Updated security agent workflow to use large runners when available ([#2270](https://github.com/newrelic/node-newrelic/pull/2270)) ([761fcd5](https://github.com/newrelic/node-newrelic/commit/761fcd5ca5ae32ba922d0880979dbfff97604641))
|
|
34
|
+
|
|
1
35
|
### v11.19.0 (2024-06-06)
|
|
2
36
|
|
|
3
37
|
#### Features
|
package/api.js
CHANGED
|
@@ -244,14 +244,14 @@ API.prototype.setControllerName = function setControllerName(name, action) {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/**
|
|
247
|
-
* Add a custom attribute to the current transaction. Some attributes are
|
|
247
|
+
* Add a custom attribute to the current transaction and span. Some attributes are
|
|
248
248
|
* reserved (see CUSTOM_DENYLIST for the current, very short list), and
|
|
249
249
|
* as with most API methods, this must be called in the context of an
|
|
250
250
|
* active transaction. Most recently set value wins.
|
|
251
251
|
*
|
|
252
252
|
* @param {string} key The key you want displayed in the RPM UI.
|
|
253
253
|
* @param {string} value The value you want displayed. Must be serializable.
|
|
254
|
-
* @returns {false|undefined}
|
|
254
|
+
* @returns {false|undefined} Returns false when disabled/errored, otherwise undefined
|
|
255
255
|
*/
|
|
256
256
|
API.prototype.addCustomAttribute = function addCustomAttribute(key, value) {
|
|
257
257
|
const metric = this.agent.metrics.getOrCreateMetric(
|
|
@@ -298,7 +298,7 @@ API.prototype.addCustomAttribute = function addCustomAttribute(key, value) {
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
/**
|
|
301
|
-
* Adds all custom attributes in an object to the current transaction.
|
|
301
|
+
* Adds all custom attributes in an object to the current transaction and span.
|
|
302
302
|
*
|
|
303
303
|
* See documentation for newrelic.addCustomAttribute for more information on
|
|
304
304
|
* setting custom attributes.
|
|
@@ -360,7 +360,7 @@ API.prototype.addCustomSpanAttributes = function addCustomSpanAttributes(atts) {
|
|
|
360
360
|
*
|
|
361
361
|
* @param {string} key The key you want displayed in the RPM UI.
|
|
362
362
|
* @param {string} value The value you want displayed. Must be serializable.
|
|
363
|
-
* @returns {false|undefined}
|
|
363
|
+
* @returns {false|undefined} Returns false when disabled/errored, otherwise undefined
|
|
364
364
|
*/
|
|
365
365
|
API.prototype.addCustomSpanAttribute = function addCustomSpanAttribute(key, value) {
|
|
366
366
|
const metric = this.agent.metrics.getOrCreateMetric(
|
|
@@ -807,7 +807,7 @@ API.prototype.getBrowserTimingHeader = function getBrowserTimingHeader(options =
|
|
|
807
807
|
* Only the first 13 chars of the license should be used for hashing with
|
|
808
808
|
* the transaction name.
|
|
809
809
|
*/
|
|
810
|
-
const key = config.license_key.
|
|
810
|
+
const key = config.license_key.substring(0, 13)
|
|
811
811
|
rumHash.transactionName = hashes.obfuscateNameUsingKey(name, key)
|
|
812
812
|
|
|
813
813
|
rumHash.queueTime = trans.queueTime
|
|
@@ -938,7 +938,7 @@ API.prototype.startSegment = function startSegment(name, record, handler, callba
|
|
|
938
938
|
* })
|
|
939
939
|
* @param {string} url
|
|
940
940
|
* The URL of the transaction. It is used to name and group related transactions in APM,
|
|
941
|
-
* so it should be a generic name and not
|
|
941
|
+
* so it should be a generic name and not include any variable parameters.
|
|
942
942
|
* @param {Function} handle
|
|
943
943
|
* Function that represents the transaction work.
|
|
944
944
|
* @returns {null|*} Returns null if handle is not a function, otherwise the return value of handle
|
|
@@ -1026,7 +1026,7 @@ API.prototype.startBackgroundTransaction = startBackgroundTransaction
|
|
|
1026
1026
|
* })
|
|
1027
1027
|
* @param {string} name
|
|
1028
1028
|
* The name of the transaction. It is used to name and group related
|
|
1029
|
-
* transactions in APM, so it should be a generic name and not
|
|
1029
|
+
* transactions in APM, so it should be a generic name and not include any
|
|
1030
1030
|
* variable parameters.
|
|
1031
1031
|
* @param {string} [group]
|
|
1032
1032
|
* Optional, used for grouping background transactions in APM. For more
|
|
@@ -1297,14 +1297,14 @@ API.prototype.recordCustomEvent = function recordCustomEvent(eventType, attribut
|
|
|
1297
1297
|
// Filter all object type valued attributes out
|
|
1298
1298
|
const filteredAttributes = _filterAttributes(attributes, `${eventType} custom event`)
|
|
1299
1299
|
|
|
1300
|
-
const
|
|
1300
|
+
const intrinsics = {
|
|
1301
1301
|
type: eventType,
|
|
1302
1302
|
timestamp: Date.now()
|
|
1303
1303
|
}
|
|
1304
1304
|
|
|
1305
1305
|
const tx = this.agent.getTransaction()
|
|
1306
1306
|
const priority = (tx && tx.priority) || Math.random()
|
|
1307
|
-
this.agent.customEventAggregator.add([
|
|
1307
|
+
this.agent.customEventAggregator.add([intrinsics, filteredAttributes], priority)
|
|
1308
1308
|
}
|
|
1309
1309
|
|
|
1310
1310
|
/**
|
|
@@ -1342,7 +1342,7 @@ API.prototype.instrument = function instrument(moduleName, onRequire, onError) {
|
|
|
1342
1342
|
* Registers an instrumentation function.
|
|
1343
1343
|
*
|
|
1344
1344
|
* - `newrelic.instrumentConglomerate(moduleName, onRequire [, onError])`
|
|
1345
|
-
* - `newrelic.
|
|
1345
|
+
* - `newrelic.instrumentConglomerate(options)`
|
|
1346
1346
|
*
|
|
1347
1347
|
* @param {string|object} moduleName The module name given to require to load the module, or the instrumentation specification
|
|
1348
1348
|
* @param {string} moduleName.moduleName The module name given to require to load the module
|
package/lib/config/default.js
CHANGED
|
@@ -519,12 +519,12 @@ defaultConfig.definition = () => ({
|
|
|
519
519
|
env: 'NEW_RELIC_TRACER_ENABLED'
|
|
520
520
|
},
|
|
521
521
|
/**
|
|
522
|
-
*
|
|
523
|
-
* transaction trace.
|
|
522
|
+
* Sets the time, in seconds, for a transaction to be considered slow.
|
|
523
|
+
* When a transaction exceeds this threshold, a transaction trace will be recorded. When set to 'apdex_f', the threshold will be set to
|
|
524
524
|
* 4 * apdex_t, which with a default apdex_t value of 500 milliseconds will
|
|
525
525
|
* be 2 seconds.
|
|
526
526
|
*
|
|
527
|
-
* If a
|
|
527
|
+
* If a number is provided, it is set in seconds.
|
|
528
528
|
*/
|
|
529
529
|
transaction_threshold: {
|
|
530
530
|
formatter: float,
|
|
@@ -35,7 +35,7 @@ class BedrockCommand {
|
|
|
35
35
|
result = this.#body.maxTokens
|
|
36
36
|
} else if (this.isClaude() === true) {
|
|
37
37
|
result = this.#body.max_tokens_to_sample
|
|
38
|
-
} else if (this.isCohere() === true) {
|
|
38
|
+
} else if (this.isClaude3() === true || this.isCohere() === true) {
|
|
39
39
|
result = this.#body.max_tokens
|
|
40
40
|
} else if (this.isLlama2() === true) {
|
|
41
41
|
result = this.#body.max_gen_length
|
|
@@ -83,6 +83,11 @@ class BedrockCommand {
|
|
|
83
83
|
this.isLlama2() === true
|
|
84
84
|
) {
|
|
85
85
|
result = this.#body.prompt
|
|
86
|
+
} else if (this.isClaude3() === true) {
|
|
87
|
+
result = this.#body?.messages?.reduce((acc, curr) => {
|
|
88
|
+
acc += curr?.content ?? ''
|
|
89
|
+
return acc
|
|
90
|
+
}, '')
|
|
86
91
|
}
|
|
87
92
|
return result
|
|
88
93
|
}
|
|
@@ -96,6 +101,7 @@ class BedrockCommand {
|
|
|
96
101
|
result = this.#body.textGenerationConfig?.temperature
|
|
97
102
|
} else if (
|
|
98
103
|
this.isClaude() === true ||
|
|
104
|
+
this.isClaude3() === true ||
|
|
99
105
|
this.isAi21() === true ||
|
|
100
106
|
this.isCohere() === true ||
|
|
101
107
|
this.isLlama2() === true
|
|
@@ -110,7 +116,11 @@ class BedrockCommand {
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
isClaude() {
|
|
113
|
-
return this.#modelId.startsWith('anthropic.claude')
|
|
119
|
+
return this.#modelId.startsWith('anthropic.claude-v')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
isClaude3() {
|
|
123
|
+
return this.#modelId.startsWith('anthropic.claude-3')
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
isCohere() {
|
|
@@ -30,10 +30,12 @@ class BedrockResponse {
|
|
|
30
30
|
#completions = []
|
|
31
31
|
#id
|
|
32
32
|
|
|
33
|
+
/* eslint-disable sonarjs/cognitive-complexity */
|
|
33
34
|
/**
|
|
34
35
|
* @param {object} params
|
|
35
36
|
* @param {AwsBedrockMiddlewareResponse} params.response
|
|
36
37
|
* @param {BedrockCommand} params.bedrockCommand
|
|
38
|
+
* @param params.isError
|
|
37
39
|
*/
|
|
38
40
|
constructor({ response, bedrockCommand, isError = false }) {
|
|
39
41
|
this.#innerResponse = isError ? response.$response : response.response
|
|
@@ -57,6 +59,14 @@ class BedrockResponse {
|
|
|
57
59
|
} else if (cmd.isClaude() === true) {
|
|
58
60
|
// TODO: can we make this thing give more than one completion?
|
|
59
61
|
body.completion && this.#completions.push(body.completion)
|
|
62
|
+
} else if (cmd.isClaude3() === true) {
|
|
63
|
+
if (body?.type === 'message_stop') {
|
|
64
|
+
// Streamed response
|
|
65
|
+
this.#completions = body.completions
|
|
66
|
+
} else {
|
|
67
|
+
this.#completions = body?.content?.map((c) => c.text)
|
|
68
|
+
}
|
|
69
|
+
this.#id = body.id
|
|
60
70
|
} else if (cmd.isCohere() === true) {
|
|
61
71
|
this.#completions = body.generations?.map((g) => g.text) ?? []
|
|
62
72
|
this.#id = body.id
|
|
@@ -66,6 +76,7 @@ class BedrockResponse {
|
|
|
66
76
|
this.#completions = body.results?.map((r) => r.outputText) ?? []
|
|
67
77
|
}
|
|
68
78
|
}
|
|
79
|
+
/* eslint-enable sonarjs/cognitive-complexity */
|
|
69
80
|
|
|
70
81
|
/**
|
|
71
82
|
* The prompt responses returned by the model.
|
|
@@ -92,7 +103,7 @@ class BedrockResponse {
|
|
|
92
103
|
const cmd = this.#command
|
|
93
104
|
if (cmd.isAi21() === true) {
|
|
94
105
|
result = this.#parsedBody.completions?.[0]?.finishReason.reason
|
|
95
|
-
} else if (cmd.isClaude() === true) {
|
|
106
|
+
} else if (cmd.isClaude() === true || cmd.isClaude3() === true) {
|
|
96
107
|
result = this.#parsedBody.stop_reason
|
|
97
108
|
} else if (cmd.isCohere() === true) {
|
|
98
109
|
result = this.#parsedBody.generations?.find((r) => r.finish_reason !== null)?.finish_reason
|
|
@@ -105,6 +105,9 @@ class StreamHandler {
|
|
|
105
105
|
if (bedrockCommand.isClaude() === true) {
|
|
106
106
|
this.stopReasonKey = 'stop_reason'
|
|
107
107
|
this.generator = handleClaude
|
|
108
|
+
} else if (bedrockCommand.isClaude3() === true) {
|
|
109
|
+
this.stopReasonKey = 'stop_reason'
|
|
110
|
+
this.generator = handleClaude3
|
|
108
111
|
} else if (bedrockCommand.isCohere() === true) {
|
|
109
112
|
this.stopReasonKey = 'generations.0.finish_reason'
|
|
110
113
|
this.generator = handleCohere
|
|
@@ -207,6 +210,31 @@ async function* handleClaude() {
|
|
|
207
210
|
}
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
async function* handleClaude3() {
|
|
214
|
+
let currentBody = {}
|
|
215
|
+
let stopReason
|
|
216
|
+
const completions = []
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
for await (const event of this.stream) {
|
|
220
|
+
yield event
|
|
221
|
+
const parsed = this.parseEvent(event)
|
|
222
|
+
this.updateHeaders(parsed)
|
|
223
|
+
currentBody = parsed
|
|
224
|
+
if (parsed.type === 'content_block_delta') {
|
|
225
|
+
completions.push(parsed.delta.text)
|
|
226
|
+
} else if (parsed.type === 'message_delta') {
|
|
227
|
+
stopReason = parsed.delta.stop_reason
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
currentBody.completions = completions
|
|
232
|
+
currentBody.stop_reason = stopReason
|
|
233
|
+
this.response.output.body = currentBody
|
|
234
|
+
this.finish()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
210
238
|
async function* handleCohere() {
|
|
211
239
|
let currentBody = {}
|
|
212
240
|
const generations = []
|
|
@@ -376,10 +376,10 @@ function parseQuery(query, nodule) {
|
|
|
376
376
|
// strip enclosing special characters from collection (table) name
|
|
377
377
|
if (typeof collection === 'string' && collection.length > 2) {
|
|
378
378
|
if (/^[\[{'"`]/.test(collection)) {
|
|
379
|
-
collection = collection.
|
|
379
|
+
collection = collection.substring(1)
|
|
380
380
|
}
|
|
381
381
|
if (/[\]}'"`]$/.test(collection)) {
|
|
382
|
-
collection = collection.
|
|
382
|
+
collection = collection.substring(0, collection.length - 1)
|
|
383
383
|
}
|
|
384
384
|
}
|
|
385
385
|
|
|
@@ -187,7 +187,7 @@ NameState.prototype.getPath = function getPath() {
|
|
|
187
187
|
if (a[0] !== '/' && path[path.length - 1] !== '/') {
|
|
188
188
|
path += '/'
|
|
189
189
|
} else if (a[0] === '/' && path[path.length - 1] === '/') {
|
|
190
|
-
a = a.
|
|
190
|
+
a = a.substring(1)
|
|
191
191
|
}
|
|
192
192
|
path += a
|
|
193
193
|
}
|
package/lib/util/hashes.js
CHANGED
|
@@ -41,7 +41,8 @@ function calculatePathHash(appName, pathName, referingPathHash) {
|
|
|
41
41
|
const result = (rotated ^ hash) >>> 0
|
|
42
42
|
|
|
43
43
|
// This is a trick to pad it out to 8 chars regardless of length.
|
|
44
|
-
|
|
44
|
+
const str = '00000000' + result.toString(16)
|
|
45
|
+
return str.substring(str.length - 8)
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function getHash(appName, txName) {
|
|
@@ -78,7 +79,7 @@ function int32ToByteArray(int32) {
|
|
|
78
79
|
// Lookup table for converting byte values to hex
|
|
79
80
|
const byteToHex = []
|
|
80
81
|
for (let i = 0; i < 256; ++i) {
|
|
81
|
-
byteToHex[i] = (i + 0x100).toString(16).
|
|
82
|
+
byteToHex[i] = (i + 0x100).toString(16).substring(1)
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
function makeId(length = 16) {
|
|
@@ -54,8 +54,10 @@ function fetchGCPInfo(agent, callback) {
|
|
|
54
54
|
agent.metrics.getOrCreateMetric(NAMES.UTILIZATION.GCP_ERROR).incrementCallCount()
|
|
55
55
|
} else {
|
|
56
56
|
// normalize
|
|
57
|
-
results.machineType = results.machineType.
|
|
58
|
-
|
|
57
|
+
results.machineType = results.machineType.substring(
|
|
58
|
+
results.machineType.lastIndexOf('/') + 1
|
|
59
|
+
)
|
|
60
|
+
results.zone = results.zone.substring(results.zone.lastIndexOf('/') + 1)
|
|
59
61
|
|
|
60
62
|
resultDict = results
|
|
61
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newrelic",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.20.0",
|
|
4
4
|
"author": "New Relic Node.js agent team <nodejs@newrelic.com>",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"contributors": [
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"lint:fix": "eslint --fix, ./*.{js,mjs} lib test bin examples",
|
|
164
164
|
"public-docs": "jsdoc -c ./jsdoc-conf.jsonc && cp examples/shim/*.png out/",
|
|
165
165
|
"publish-docs": "./bin/publish-docs.sh",
|
|
166
|
-
"services": "docker compose up -d --wait",
|
|
166
|
+
"services": "DOCKER_PLATFORM=linux/$(uname -m) docker compose up -d --wait",
|
|
167
167
|
"services:stop": "docker compose down",
|
|
168
168
|
"smoke": "npm run ssl && time tap test/smoke/**/**/*.tap.js --timeout=180 --no-coverage",
|
|
169
169
|
"ssl": "./bin/ssl.sh",
|