bytifi 0.2.6 → 0.2.7
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/LICENSE +21 -0
- package/README.md +19 -5
- package/bin/bytifi.js +168 -66
- package/lib/api.js +203 -0
- package/lib/base64url.js +10 -2
- package/lib/crypto.js +0 -1
- package/lib/decrypt.js +184 -136
- package/lib/progress.js +50 -0
- package/lib/upload.js +94 -261
- package/lib/url.js +26 -0
- package/package.json +5 -2
package/lib/upload.js
CHANGED
|
@@ -1,228 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import {
|
|
3
|
+
apiFetchWithRetry,
|
|
4
|
+
sleep,
|
|
5
|
+
} from './api.js'
|
|
1
6
|
import {
|
|
2
7
|
MULTIPART_THRESHOLD_BYTES,
|
|
3
8
|
encryptChunkFromFile,
|
|
4
9
|
resolveUploadFile,
|
|
5
10
|
} from './crypto.js'
|
|
6
|
-
import { formatDuration } from './progress.js'
|
|
7
|
-
import fs from 'node:fs/promises'
|
|
8
11
|
|
|
9
|
-
const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
10
12
|
const DEFAULT_CONCURRENCY = 4
|
|
11
13
|
const POLL_INTERVAL_MS = 1500
|
|
12
14
|
const POLL_TIMEOUT_MS = 30 * 60 * 1000
|
|
13
|
-
const MAX_RETRIES = 3
|
|
14
15
|
const UNLIMITED_RATE_LIMIT_RETRIES = Number.POSITIVE_INFINITY
|
|
15
16
|
|
|
16
|
-
export
|
|
17
|
-
constructor(message, { status = 0, body = null, retryAfterMs = null } = {}) {
|
|
18
|
-
super(message)
|
|
19
|
-
this.name = 'BytifiApiError'
|
|
20
|
-
this.status = status
|
|
21
|
-
this.body = body
|
|
22
|
-
this.retryAfterMs = retryAfterMs
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export class BytifiNetworkError extends Error {
|
|
27
|
-
constructor(message, { cause } = {}) {
|
|
28
|
-
super(message)
|
|
29
|
-
this.name = 'BytifiNetworkError'
|
|
30
|
-
this.cause = cause
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function sleep(ms) {
|
|
35
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function normalizeBaseUrl(baseUrl) {
|
|
39
|
-
return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isRetryableError(error) {
|
|
43
|
-
if (error instanceof BytifiNetworkError) return true
|
|
44
|
-
if (error instanceof BytifiApiError) {
|
|
45
|
-
return error.status === 429 || error.status >= 500
|
|
46
|
-
}
|
|
47
|
-
return false
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function parseRetryAfterMs(response) {
|
|
51
|
-
const retryAfter = response.headers.get('Retry-After')
|
|
52
|
-
if (retryAfter) {
|
|
53
|
-
const seconds = Number(retryAfter)
|
|
54
|
-
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
55
|
-
return seconds * 1000
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const dateMs = Date.parse(retryAfter)
|
|
59
|
-
if (Number.isFinite(dateMs)) {
|
|
60
|
-
return Math.max(0, dateMs - Date.now())
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const resetHeader = response.headers.get('RateLimit-Reset')
|
|
65
|
-
if (resetHeader) {
|
|
66
|
-
const resetValue = Number(resetHeader)
|
|
67
|
-
if (Number.isFinite(resetValue)) {
|
|
68
|
-
if (resetValue > 1_000_000_000) {
|
|
69
|
-
return Math.max(0, resetValue * 1000 - Date.now())
|
|
70
|
-
}
|
|
71
|
-
return Math.max(0, resetValue * 1000)
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return null
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function readResponseBody(response) {
|
|
79
|
-
const text = await response.text()
|
|
80
|
-
if (!text) return null
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
return JSON.parse(text)
|
|
84
|
-
} catch {
|
|
85
|
-
return text
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal, binary = false } = {}) {
|
|
90
|
-
const url = `${normalizeBaseUrl(baseUrl)}${path}`
|
|
91
|
-
const requestHeaders = {
|
|
92
|
-
...headers,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (apiKey) {
|
|
96
|
-
requestHeaders.Authorization = `Bearer ${apiKey}`
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
let response
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
response = await fetch(url, {
|
|
103
|
-
method,
|
|
104
|
-
headers: requestHeaders,
|
|
105
|
-
body,
|
|
106
|
-
signal,
|
|
107
|
-
})
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (signal?.aborted) {
|
|
110
|
-
throw new Error('Upload aborted.')
|
|
111
|
-
}
|
|
112
|
-
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const responseBuffer = Buffer.from(await response.arrayBuffer())
|
|
116
|
-
|
|
117
|
-
if (!response.ok) {
|
|
118
|
-
const text = responseBuffer.toString('utf8')
|
|
119
|
-
let payload = text
|
|
120
|
-
try {
|
|
121
|
-
payload = JSON.parse(text)
|
|
122
|
-
} catch {
|
|
123
|
-
// keep text
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const message = typeof payload === 'object' && payload?.error
|
|
127
|
-
? payload.error
|
|
128
|
-
: typeof payload === 'string' && payload
|
|
129
|
-
? payload
|
|
130
|
-
: `Request failed with status ${response.status}.`
|
|
131
|
-
throw new BytifiApiError(message, {
|
|
132
|
-
status: response.status,
|
|
133
|
-
body: payload,
|
|
134
|
-
retryAfterMs: response.status === 429 ? parseRetryAfterMs(response) : null,
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (binary) {
|
|
139
|
-
return responseBuffer
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const text = responseBuffer.toString('utf8')
|
|
143
|
-
if (!text) return null
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
return JSON.parse(text)
|
|
147
|
-
} catch {
|
|
148
|
-
return text
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function apiFetchWithRetry(baseUrl, path, options = {}) {
|
|
153
|
-
const {
|
|
154
|
-
retries = MAX_RETRIES,
|
|
155
|
-
rateLimitRetries = 0,
|
|
156
|
-
maxRateLimitWaitMs = null,
|
|
157
|
-
onStatus,
|
|
158
|
-
signal,
|
|
159
|
-
...fetchOptions
|
|
160
|
-
} = options
|
|
161
|
-
|
|
162
|
-
let attempt = 0
|
|
163
|
-
let rateLimitAttempt = 0
|
|
164
|
-
const maxRateLimitRetries = rateLimitRetries === UNLIMITED_RATE_LIMIT_RETRIES
|
|
165
|
-
? UNLIMITED_RATE_LIMIT_RETRIES
|
|
166
|
-
: (rateLimitRetries || retries)
|
|
167
|
-
|
|
168
|
-
while (true) {
|
|
169
|
-
try {
|
|
170
|
-
return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
|
|
171
|
-
} catch (error) {
|
|
172
|
-
if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
|
|
173
|
-
throw error
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (error instanceof BytifiApiError && error.status === 429) {
|
|
177
|
-
if (rateLimitAttempt >= maxRateLimitRetries) {
|
|
178
|
-
throw error
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
rateLimitAttempt += 1
|
|
182
|
-
const serverWaitMs = error.retryAfterMs ?? Math.min(600_000, 15_000 * rateLimitAttempt)
|
|
183
|
-
const waitMs = maxRateLimitWaitMs == null
|
|
184
|
-
? serverWaitMs
|
|
185
|
-
: Math.min(serverWaitMs, maxRateLimitWaitMs)
|
|
186
|
-
|
|
187
|
-
onStatus?.({
|
|
188
|
-
stage: 'waiting',
|
|
189
|
-
message: maxRateLimitWaitMs != null && serverWaitMs > waitMs
|
|
190
|
-
? `Rate limited — retrying in ${formatDuration(waitMs)} (part upload continues when server allows)…`
|
|
191
|
-
: `Rate limited — waiting ${formatDuration(waitMs)} before retry (${rateLimitAttempt})…`,
|
|
192
|
-
waitMs,
|
|
193
|
-
retryAttempt: rateLimitAttempt,
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
await sleep(waitMs)
|
|
197
|
-
continue
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (!isRetryableError(error) || attempt >= retries) {
|
|
201
|
-
throw error
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
attempt += 1
|
|
205
|
-
await sleep(Math.min(1000 * (2 ** attempt), 8000))
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export async function publicFetchWithRetry(baseUrl, path, options = {}) {
|
|
211
|
-
return apiFetchWithRetry(baseUrl, path, {
|
|
212
|
-
...options,
|
|
213
|
-
apiKey: null,
|
|
214
|
-
rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export async function fetchPublicBinaryWithRetry(baseUrl, path, options = {}) {
|
|
219
|
-
return apiFetchWithRetry(baseUrl, path, {
|
|
220
|
-
...options,
|
|
221
|
-
apiKey: null,
|
|
222
|
-
binary: true,
|
|
223
|
-
rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
|
|
224
|
-
})
|
|
225
|
-
}
|
|
17
|
+
export { BytifiApiError, BytifiNetworkError, fetchPublicBinaryWithRetry, publicFetchWithRetry } from './api.js'
|
|
226
18
|
|
|
227
19
|
function buildShareUrl(payload, encryptionToken) {
|
|
228
20
|
return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
|
|
@@ -245,6 +37,11 @@ function buildResult(payload, context, shareUrl) {
|
|
|
245
37
|
}
|
|
246
38
|
}
|
|
247
39
|
|
|
40
|
+
function uploadProgressPercent(completedParts, inFlightParts, totalParts) {
|
|
41
|
+
const weighted = completedParts + inFlightParts * 0.5
|
|
42
|
+
return Math.min(100, Math.round((weighted / totalParts) * 100))
|
|
43
|
+
}
|
|
44
|
+
|
|
248
45
|
async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
|
|
249
46
|
const fileHandle = await fs.open(filePath, 'r')
|
|
250
47
|
const encryptedParts = []
|
|
@@ -263,6 +60,11 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
|
|
|
263
60
|
})
|
|
264
61
|
|
|
265
62
|
encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
|
|
63
|
+
|
|
64
|
+
if (signal?.aborted) {
|
|
65
|
+
throw new Error('Upload aborted.')
|
|
66
|
+
}
|
|
67
|
+
|
|
266
68
|
onProgress?.({
|
|
267
69
|
stage: 'encrypted',
|
|
268
70
|
part: chunkIndex + 1,
|
|
@@ -359,6 +161,17 @@ function resolveUploadConcurrency(originalSize, requested) {
|
|
|
359
161
|
return DEFAULT_CONCURRENCY
|
|
360
162
|
}
|
|
361
163
|
|
|
164
|
+
async function abortUploadSession(baseUrl, sessionToken, { apiKey, signal }) {
|
|
165
|
+
await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
|
|
166
|
+
apiKey,
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ sessionToken }),
|
|
170
|
+
signal,
|
|
171
|
+
retries: 1,
|
|
172
|
+
}).catch(() => {})
|
|
173
|
+
}
|
|
174
|
+
|
|
362
175
|
async function uploadMultipartStreaming(filePath, context, {
|
|
363
176
|
apiKey,
|
|
364
177
|
baseUrl,
|
|
@@ -403,8 +216,20 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
403
216
|
context.chunkCount,
|
|
404
217
|
)
|
|
405
218
|
const fileHandle = await fs.open(filePath, 'r')
|
|
219
|
+
const workerAbort = new AbortController()
|
|
406
220
|
let nextChunkIndex = 0
|
|
407
221
|
let completedParts = 0
|
|
222
|
+
let inFlightParts = 0
|
|
223
|
+
let sessionAborted = false
|
|
224
|
+
|
|
225
|
+
const combinedSignal = AbortSignal.any?.([signal, workerAbort.signal])
|
|
226
|
+
?? (() => {
|
|
227
|
+
const controller = new AbortController()
|
|
228
|
+
const abort = () => controller.abort()
|
|
229
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
230
|
+
workerAbort.signal.addEventListener('abort', abort, { once: true })
|
|
231
|
+
return controller.signal
|
|
232
|
+
})()
|
|
408
233
|
|
|
409
234
|
onProgress?.({
|
|
410
235
|
stage: 'uploading',
|
|
@@ -412,6 +237,16 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
412
237
|
detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
|
|
413
238
|
})
|
|
414
239
|
|
|
240
|
+
function reportPartProgress(stage, partNumber, partBytes) {
|
|
241
|
+
onProgress?.({
|
|
242
|
+
stage,
|
|
243
|
+
part: partNumber,
|
|
244
|
+
totalParts: context.chunkCount,
|
|
245
|
+
partBytes,
|
|
246
|
+
percent: uploadProgressPercent(completedParts, inFlightParts, context.chunkCount),
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
415
250
|
function claimChunkIndex() {
|
|
416
251
|
const chunkIndex = nextChunkIndex
|
|
417
252
|
nextChunkIndex += 1
|
|
@@ -420,7 +255,7 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
420
255
|
|
|
421
256
|
async function pipelineWorker() {
|
|
422
257
|
while (true) {
|
|
423
|
-
if (
|
|
258
|
+
if (combinedSignal.aborted) {
|
|
424
259
|
throw new Error('Upload aborted.')
|
|
425
260
|
}
|
|
426
261
|
|
|
@@ -430,62 +265,60 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
430
265
|
}
|
|
431
266
|
|
|
432
267
|
const partNumber = chunkIndex + 1
|
|
268
|
+
inFlightParts += 1
|
|
433
269
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
part: partNumber,
|
|
437
|
-
totalParts: context.chunkCount,
|
|
438
|
-
percent: Math.round((completedParts / context.chunkCount) * 100),
|
|
439
|
-
})
|
|
270
|
+
try {
|
|
271
|
+
reportPartProgress('encrypting', partNumber)
|
|
440
272
|
|
|
441
|
-
|
|
273
|
+
const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
|
|
442
274
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
totalParts: context.chunkCount,
|
|
447
|
-
partBytes: encryptedPart.length,
|
|
448
|
-
percent: Math.round((completedParts / context.chunkCount) * 100),
|
|
449
|
-
})
|
|
275
|
+
if (combinedSignal.aborted) {
|
|
276
|
+
throw new Error('Upload aborted.')
|
|
277
|
+
}
|
|
450
278
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
279
|
+
reportPartProgress('uploading', partNumber, encryptedPart.length)
|
|
280
|
+
|
|
281
|
+
await apiFetchWithRetry(
|
|
282
|
+
baseUrl,
|
|
283
|
+
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
284
|
+
{
|
|
285
|
+
apiKey,
|
|
286
|
+
method: 'PUT',
|
|
287
|
+
headers: {
|
|
288
|
+
'Content-Type': 'application/octet-stream',
|
|
289
|
+
'X-Upload-Session': sessionToken,
|
|
290
|
+
},
|
|
291
|
+
body: encryptedPart,
|
|
292
|
+
signal: combinedSignal,
|
|
293
|
+
onStatus,
|
|
294
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
295
|
+
maxRateLimitWaitMs: 15_000,
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
completedParts += 1
|
|
300
|
+
onProgress?.({
|
|
301
|
+
stage: 'uploaded',
|
|
302
|
+
part: partNumber,
|
|
303
|
+
totalParts: context.chunkCount,
|
|
304
|
+
partBytes: encryptedPart.length,
|
|
305
|
+
percent: uploadProgressPercent(completedParts, inFlightParts, context.chunkCount),
|
|
306
|
+
detail: `${completedParts}/${context.chunkCount} parts done`,
|
|
307
|
+
})
|
|
308
|
+
} finally {
|
|
309
|
+
inFlightParts -= 1
|
|
310
|
+
}
|
|
475
311
|
}
|
|
476
312
|
}
|
|
477
313
|
|
|
478
314
|
try {
|
|
479
315
|
await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
|
|
480
316
|
} catch (error) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
body: JSON.stringify({ sessionToken }),
|
|
487
|
-
signal,
|
|
488
|
-
}).catch(() => {})
|
|
317
|
+
workerAbort.abort()
|
|
318
|
+
|
|
319
|
+
if (!sessionAborted) {
|
|
320
|
+
sessionAborted = true
|
|
321
|
+
await abortUploadSession(baseUrl, sessionToken, { apiKey, signal })
|
|
489
322
|
}
|
|
490
323
|
|
|
491
324
|
throw error
|
package/lib/url.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
2
|
+
|
|
3
|
+
export function normalizeBaseUrl(baseUrl) {
|
|
4
|
+
return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function validateBaseUrl(url) {
|
|
8
|
+
const normalized = normalizeBaseUrl(url)
|
|
9
|
+
|
|
10
|
+
let parsed
|
|
11
|
+
try {
|
|
12
|
+
parsed = new URL(normalized)
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(`Invalid base URL: ${url}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
18
|
+
throw new Error(`Invalid base URL protocol: ${parsed.protocol} (use http: or https:)`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!parsed.hostname) {
|
|
22
|
+
throw new Error('Invalid base URL: missing hostname.')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return normalized
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bytifi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Official Bytifi CLI — encrypt, upload, and decrypt files from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"author": "Bytifi",
|
|
6
7
|
"bin": {
|
|
7
8
|
"bytifi": "./bin/bytifi.js"
|
|
8
9
|
},
|
|
@@ -14,10 +15,12 @@
|
|
|
14
15
|
"node": ">=18"
|
|
15
16
|
},
|
|
16
17
|
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.js",
|
|
17
19
|
"upload": "node bin/bytifi.js upload",
|
|
18
20
|
"decrypt": "node bin/bytifi.js decrypt",
|
|
19
21
|
"build:bundle": "node scripts/build-bundle.mjs",
|
|
20
|
-
"build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip --public --public-packages \"*\""
|
|
22
|
+
"build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip --public --public-packages \"*\"",
|
|
23
|
+
"prepublishOnly": "npm test"
|
|
21
24
|
},
|
|
22
25
|
"repository": {
|
|
23
26
|
"type": "git",
|