@opencrvs/create-countryconfig 2.0.1 → 2.1.0-beta
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/index.js +293 -103
- package/package.json +5 -2
package/index.js
CHANGED
|
@@ -11,25 +11,37 @@
|
|
|
11
11
|
* Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const { execSync } = require('child_process')
|
|
15
14
|
const path = require('path')
|
|
16
15
|
const fs = require('fs')
|
|
16
|
+
const { execSync } = require('child_process')
|
|
17
|
+
const readline = require('readline/promises')
|
|
18
|
+
const degit = require('degit').default
|
|
17
19
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const
|
|
20
|
+
const INFRASTRUCTURE_REPOSITORY = 'opencrvs/infrastructure'
|
|
21
|
+
const CORE_REPOSITORY = 'opencrvs/opencrvs-core'
|
|
22
|
+
const COUNTRYCONFIG_TEMPLATE_REPOSITORY_SUBPATH =
|
|
23
|
+
'packages/countryconfig-template'
|
|
24
|
+
|
|
25
|
+
const CORE_REPO_URL = 'https://github.com/' + CORE_REPOSITORY + '.git'
|
|
26
|
+
const INFRASTRUCTURE_REPO_URL =
|
|
27
|
+
'https://github.com/' + INFRASTRUCTURE_REPOSITORY + '.git'
|
|
21
28
|
|
|
22
29
|
const { version } = JSON.parse(
|
|
23
30
|
fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8')
|
|
24
31
|
)
|
|
25
32
|
|
|
33
|
+
function joinValues(values, separator) {
|
|
34
|
+
return values
|
|
35
|
+
.filter((value) => !!value)
|
|
36
|
+
.join(separator)
|
|
37
|
+
.trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
function tagExists(repoUrl, tag) {
|
|
27
41
|
try {
|
|
28
42
|
execSync(
|
|
29
43
|
'git ls-remote --exit-code --tags ' + repoUrl + ' refs/tags/' + tag,
|
|
30
|
-
{
|
|
31
|
-
stdio: 'pipe'
|
|
32
|
-
}
|
|
44
|
+
{ stdio: 'pipe' }
|
|
33
45
|
)
|
|
34
46
|
return true
|
|
35
47
|
} catch (err) {
|
|
@@ -41,9 +53,7 @@ function branchExists(repoUrl, branch) {
|
|
|
41
53
|
try {
|
|
42
54
|
execSync(
|
|
43
55
|
'git ls-remote --exit-code --heads ' + repoUrl + ' refs/heads/' + branch,
|
|
44
|
-
{
|
|
45
|
-
stdio: 'pipe'
|
|
46
|
-
}
|
|
56
|
+
{ stdio: 'pipe' }
|
|
47
57
|
)
|
|
48
58
|
return true
|
|
49
59
|
} catch (err) {
|
|
@@ -80,9 +90,8 @@ function listReleaseTags(repoUrl) {
|
|
|
80
90
|
function getLatestCommonReleaseTag() {
|
|
81
91
|
const infrastructureTags = new Set(listReleaseTags(INFRASTRUCTURE_REPO_URL))
|
|
82
92
|
return (
|
|
83
|
-
listReleaseTags(
|
|
84
|
-
|
|
85
|
-
) || null
|
|
93
|
+
listReleaseTags(CORE_REPO_URL).find((tag) => infrastructureTags.has(tag)) ||
|
|
94
|
+
null
|
|
86
95
|
)
|
|
87
96
|
}
|
|
88
97
|
|
|
@@ -111,7 +120,7 @@ function resolveRef() {
|
|
|
111
120
|
if (version.includes('-')) {
|
|
112
121
|
const releaseBranch = 'release/' + version.split('-')[0]
|
|
113
122
|
if (
|
|
114
|
-
branchExists(
|
|
123
|
+
branchExists(CORE_REPO_URL, releaseBranch) &&
|
|
115
124
|
branchExists(INFRASTRUCTURE_REPO_URL, releaseBranch)
|
|
116
125
|
) {
|
|
117
126
|
return releaseBranch
|
|
@@ -121,7 +130,7 @@ function resolveRef() {
|
|
|
121
130
|
|
|
122
131
|
const tag = 'v' + version
|
|
123
132
|
if (
|
|
124
|
-
tagExists(
|
|
133
|
+
tagExists(CORE_REPO_URL, tag) &&
|
|
125
134
|
tagExists(INFRASTRUCTURE_REPO_URL, tag)
|
|
126
135
|
) {
|
|
127
136
|
return tag
|
|
@@ -141,126 +150,307 @@ function resolveRef() {
|
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
console.error(
|
|
144
|
-
'\nError: no matching release tag was found in both the
|
|
153
|
+
'\nError: no matching release tag was found in both the core and ' +
|
|
145
154
|
'infrastructure repositories.'
|
|
146
155
|
)
|
|
147
156
|
process.exit(1)
|
|
148
157
|
}
|
|
149
158
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Clones a repository from GitHub to a target directory.
|
|
161
|
+
*
|
|
162
|
+
* @param {*} param0 repository - The repository to clone (e.g., 'opencrvs/opencrvs-core').
|
|
163
|
+
* @param {*} param0 repositorySubPath - The subpath within the repository to clone (optional). Otherwise the entire repository will be cloned.
|
|
164
|
+
* @param {*} param0 branch - The branch to clone (optional). Defaults to the default branch if not specified.
|
|
165
|
+
*
|
|
166
|
+
* @param {*} targetDir - The target directory where the repository will be cloned.
|
|
167
|
+
*/
|
|
168
|
+
async function cloneRepository(
|
|
169
|
+
{ repository, repositorySubPath, branch },
|
|
170
|
+
targetDir
|
|
171
|
+
) {
|
|
172
|
+
const repositoryPath = joinValues([repository, repositorySubPath], '/')
|
|
173
|
+
const fullPath = joinValues([repositoryPath, branch], '#')
|
|
174
|
+
|
|
175
|
+
console.log(`Cloning repository from ${fullPath} to ${targetDir}...`)
|
|
176
|
+
|
|
177
|
+
const emitter = degit(fullPath, {
|
|
178
|
+
mode: 'git'
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
await emitter.clone(targetDir)
|
|
182
|
+
console.log(`Copied files from ${fullPath} to ${targetDir} succesfully.`)
|
|
155
183
|
}
|
|
156
184
|
|
|
157
|
-
|
|
185
|
+
function ensureTargetDirectoryDoesNotExist(directoryName) {
|
|
186
|
+
const targetDirectoryPath = path.resolve(process.cwd(), directoryName)
|
|
158
187
|
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
188
|
+
if (fs.existsSync(targetDirectoryPath)) {
|
|
189
|
+
console.error(
|
|
190
|
+
'Error: Directory already exists in path "' + targetDirectoryPath + '".'
|
|
191
|
+
)
|
|
192
|
+
process.exit(1)
|
|
193
|
+
}
|
|
164
194
|
}
|
|
165
195
|
|
|
166
|
-
|
|
167
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Asks whether to enable telemetry. Defaults to yes, and answers yes without
|
|
198
|
+
* prompting when not attached to a terminal (e.g. non-interactive scaffolding).
|
|
199
|
+
*/
|
|
200
|
+
async function promptEnableTelemetry() {
|
|
201
|
+
if (!process.stdin.isTTY) {
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
168
204
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
205
|
+
const rl = readline.createInterface({
|
|
206
|
+
input: process.stdin,
|
|
207
|
+
output: process.stdout
|
|
208
|
+
})
|
|
209
|
+
try {
|
|
210
|
+
const answer = (
|
|
211
|
+
await rl.question(
|
|
212
|
+
'\nEnable anonymous usage telemetry to help improve OpenCRVS? Only ' +
|
|
213
|
+
'aggregate metrics are shared — no personal or protected data. [Y/n] '
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
.trim()
|
|
217
|
+
.toLowerCase()
|
|
218
|
+
return answer === '' || answer === 'y' || answer === 'yes'
|
|
219
|
+
} finally {
|
|
220
|
+
rl.close()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
174
223
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Prompts for a single line of input, re-asking until `validate` accepts the
|
|
226
|
+
* trimmed answer. `validate` returns an error message string when the answer is
|
|
227
|
+
* invalid, or a falsy value when it is accepted. Exits when not attached to a
|
|
228
|
+
* terminal, since a mandatory value cannot be gathered non-interactively.
|
|
229
|
+
*/
|
|
230
|
+
async function promptRequired(question, validate) {
|
|
231
|
+
if (!process.stdin.isTTY) {
|
|
232
|
+
console.error(
|
|
233
|
+
'\nError: interactive input is required to set the organisation name and country code.'
|
|
234
|
+
)
|
|
235
|
+
process.exit(1)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const rl = readline.createInterface({
|
|
239
|
+
input: process.stdin,
|
|
240
|
+
output: process.stdout
|
|
241
|
+
})
|
|
242
|
+
try {
|
|
243
|
+
while (true) {
|
|
244
|
+
const answer = (await rl.question(question)).trim()
|
|
245
|
+
const error = validate(answer)
|
|
246
|
+
if (!error) {
|
|
247
|
+
return answer
|
|
248
|
+
}
|
|
249
|
+
console.error(error)
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
rl.close()
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Prompts for the organisation name reported with telemetry. Mandatory.
|
|
258
|
+
*/
|
|
259
|
+
function promptOrganisation() {
|
|
260
|
+
return promptRequired('\nOrganisation running this instance: ', (answer) =>
|
|
261
|
+
answer === '' ? 'Please enter an organisation name.' : undefined
|
|
178
262
|
)
|
|
179
|
-
process.exit(1)
|
|
180
263
|
}
|
|
181
264
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Prompts for the alpha-3 ISO country code reported with telemetry, re-asking
|
|
267
|
+
* until a valid three-letter code is given. Mandatory.
|
|
268
|
+
*/
|
|
269
|
+
async function promptCountryCode() {
|
|
270
|
+
const answer = await promptRequired(
|
|
271
|
+
'\nAlpha-3 ISO country code of this instance (e.g. "GBR"): ',
|
|
272
|
+
(value) =>
|
|
273
|
+
/^[A-Za-z]{3}$/.test(value)
|
|
274
|
+
? undefined
|
|
275
|
+
: 'Please enter a three-letter alpha-3 ISO country code (e.g. "GBR").'
|
|
185
276
|
)
|
|
186
|
-
|
|
277
|
+
return answer.toUpperCase()
|
|
187
278
|
}
|
|
188
279
|
|
|
189
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Flips the `TELEMETRY_ENABLED` env var default in the cloned country config's
|
|
282
|
+
* environment to `true`. The template ships it defaulting to `false`.
|
|
283
|
+
*/
|
|
284
|
+
function enableTelemetryInEnvironment(targetPath) {
|
|
285
|
+
const environmentPath = path.join(targetPath, 'src', 'environment.ts')
|
|
286
|
+
if (!fs.existsSync(environmentPath)) {
|
|
287
|
+
console.warn(
|
|
288
|
+
'\nWarning: could not find src/environment.ts; telemetry default not changed.'
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
190
292
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
293
|
+
const original = fs.readFileSync(environmentPath, 'utf-8')
|
|
294
|
+
const updated = original.replace(
|
|
295
|
+
/(TELEMETRY_ENABLED:\s*bool\(\{[\s\S]*?default:\s*)false/,
|
|
296
|
+
'$1true'
|
|
297
|
+
)
|
|
194
298
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
299
|
+
if (updated === original) {
|
|
300
|
+
console.warn(
|
|
301
|
+
'\nWarning: could not update the TELEMETRY_ENABLED default in src/environment.ts.'
|
|
302
|
+
)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
fs.writeFileSync(environmentPath, updated)
|
|
307
|
+
console.log('\nTelemetry enabled (TELEMETRY_ENABLED now defaults to true).')
|
|
200
308
|
}
|
|
201
309
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
'
|
|
210
|
-
err.message
|
|
310
|
+
/**
|
|
311
|
+
* Replaces the string `default` of an envalid `str({ ... })` field in the
|
|
312
|
+
* cloned country config's environment. Returns the updated source, or the
|
|
313
|
+
* original source (with a warning) when the field could not be located.
|
|
314
|
+
*/
|
|
315
|
+
function setEnvironmentStringDefault(source, key, value) {
|
|
316
|
+
const pattern = new RegExp(
|
|
317
|
+
`(${key}:\\s*str\\(\\{[\\s\\S]*?default:\\s*)'[^']*'`
|
|
211
318
|
)
|
|
212
|
-
|
|
319
|
+
// Escape for a single-quoted TS string literal, and use a function replacer
|
|
320
|
+
// so `$` in the value is not treated as a replacement pattern.
|
|
321
|
+
const literal = "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"
|
|
322
|
+
const updated = source.replace(pattern, (_, prefix) => prefix + literal)
|
|
323
|
+
|
|
324
|
+
if (updated === source) {
|
|
325
|
+
console.warn(
|
|
326
|
+
`\nWarning: could not update the ${key} default in src/environment.ts.`
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return updated
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Writes the given organisation name and alpha-3 country code as the defaults
|
|
335
|
+
* for the `ORGANISATION` and `COUNTRY_CODE` env vars in the cloned country
|
|
336
|
+
* config's environment.
|
|
337
|
+
*/
|
|
338
|
+
function setTelemetryIdentityInEnvironment(
|
|
339
|
+
targetPath,
|
|
340
|
+
{ organisation, countryCode }
|
|
341
|
+
) {
|
|
342
|
+
const environmentPath = path.join(targetPath, 'src', 'environment.ts')
|
|
343
|
+
if (!fs.existsSync(environmentPath)) {
|
|
344
|
+
console.warn(
|
|
345
|
+
'\nWarning: could not find src/environment.ts; organisation and country code defaults not changed.'
|
|
346
|
+
)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let source = fs.readFileSync(environmentPath, 'utf-8')
|
|
351
|
+
source = setEnvironmentStringDefault(source, 'ORGANISATION', organisation)
|
|
352
|
+
source = setEnvironmentStringDefault(source, 'COUNTRY_CODE', countryCode)
|
|
353
|
+
fs.writeFileSync(environmentPath, source)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function updatePackageJsonName(targetPath, newName) {
|
|
357
|
+
console.log('\nUpdating package.json with project name: ' + newName + '\n')
|
|
358
|
+
|
|
359
|
+
const pkgPath = path.join(targetPath, 'package.json')
|
|
360
|
+
if (fs.existsSync(pkgPath)) {
|
|
361
|
+
try {
|
|
362
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
363
|
+
pkg.name = newName
|
|
364
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
365
|
+
} catch (err) {
|
|
366
|
+
console.error('\nFailed to update package.json:', err.message)
|
|
367
|
+
process.exit(1)
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
console.warn(
|
|
371
|
+
'\nWarning: No package.json found in the targetPath: ' +
|
|
372
|
+
targetPath +
|
|
373
|
+
'. Project name was not updated.'
|
|
374
|
+
)
|
|
375
|
+
}
|
|
213
376
|
}
|
|
214
377
|
|
|
215
|
-
|
|
216
|
-
|
|
378
|
+
async function main() {
|
|
379
|
+
const projectName = process.argv[2]
|
|
380
|
+
|
|
381
|
+
if (!projectName) {
|
|
382
|
+
console.error(
|
|
383
|
+
'Please specify a project name:\n\n npm create @opencrvs/countryconfig <project-name>\n'
|
|
384
|
+
)
|
|
385
|
+
process.exit(1)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const countryconfigDirName = projectName + '-countryconfig'
|
|
389
|
+
const countryconfigTargetPath = path.resolve(
|
|
390
|
+
process.cwd(),
|
|
391
|
+
countryconfigDirName
|
|
392
|
+
)
|
|
393
|
+
const infrastructureDirName = projectName + '-infrastructure'
|
|
394
|
+
const infrastructureTargetPath = path.resolve(
|
|
395
|
+
process.cwd(),
|
|
396
|
+
infrastructureDirName
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
ensureTargetDirectoryDoesNotExist(countryconfigDirName)
|
|
400
|
+
ensureTargetDirectoryDoesNotExist(infrastructureDirName)
|
|
401
|
+
|
|
402
|
+
const ref = resolveRef()
|
|
403
|
+
|
|
404
|
+
// Gather all answers up front so the operator isn't interrupted mid-clone.
|
|
405
|
+
const organisation = await promptOrganisation()
|
|
406
|
+
const countryCode = await promptCountryCode()
|
|
407
|
+
const telemetryEnabled = await promptEnableTelemetry()
|
|
408
|
+
|
|
217
409
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
410
|
+
await cloneRepository(
|
|
411
|
+
{
|
|
412
|
+
repository: CORE_REPOSITORY,
|
|
413
|
+
repositorySubPath: COUNTRYCONFIG_TEMPLATE_REPOSITORY_SUBPATH,
|
|
414
|
+
branch: ref
|
|
415
|
+
},
|
|
416
|
+
countryconfigTargetPath
|
|
417
|
+
)
|
|
221
418
|
} catch (err) {
|
|
222
|
-
console.error('
|
|
419
|
+
console.error('\nFailed to clone country config template:', err.message)
|
|
223
420
|
process.exit(1)
|
|
224
421
|
}
|
|
225
|
-
} else {
|
|
226
|
-
console.warn(
|
|
227
|
-
'Warning: No package.json found in the cloned country config repository. Project name was not updated.'
|
|
228
|
-
)
|
|
229
|
-
}
|
|
230
422
|
|
|
231
|
-
|
|
232
|
-
'\nScaffolding OpenCRVS infrastructure in ./' +
|
|
233
|
-
infrastructureDirName +
|
|
234
|
-
'...\n'
|
|
235
|
-
)
|
|
423
|
+
updatePackageJsonName(countryconfigTargetPath, countryconfigDirName)
|
|
236
424
|
|
|
237
|
-
try {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
425
|
+
try {
|
|
426
|
+
await cloneRepository(
|
|
427
|
+
{ repository: INFRASTRUCTURE_REPOSITORY, branch: ref },
|
|
428
|
+
infrastructureTargetPath
|
|
429
|
+
)
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error('Failed to clone the infrastructure repository:', err.message)
|
|
432
|
+
process.exit(1)
|
|
433
|
+
}
|
|
243
434
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
force: true
|
|
435
|
+
setTelemetryIdentityInEnvironment(countryconfigTargetPath, {
|
|
436
|
+
organisation,
|
|
437
|
+
countryCode
|
|
248
438
|
})
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
439
|
+
|
|
440
|
+
if (telemetryEnabled) {
|
|
441
|
+
enableTelemetryInEnvironment(countryconfigTargetPath)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
console.log('\nDone! Your project has been set up in two directories:\n')
|
|
445
|
+
console.log(' ./' + countryconfigDirName + ' -- country configuration')
|
|
446
|
+
console.log(' ./' + infrastructureDirName + ' -- server infrastructure\n')
|
|
447
|
+
console.log('To get started with the country config:\n')
|
|
448
|
+
console.log(' cd ' + countryconfigDirName)
|
|
449
|
+
console.log(' git init')
|
|
450
|
+
console.log(' tilt up\n')
|
|
451
|
+
console.log('To get started with the infrastructure:\n')
|
|
452
|
+
console.log(' cd ' + infrastructureDirName)
|
|
453
|
+
console.log(' git init\n')
|
|
255
454
|
}
|
|
256
455
|
|
|
257
|
-
|
|
258
|
-
console.log(' ./' + countryconfigDirName + ' -- country configuration')
|
|
259
|
-
console.log(' ./' + infrastructureDirName + ' -- server infrastructure\n')
|
|
260
|
-
console.log('To get started with the country config:\n')
|
|
261
|
-
console.log(' cd ' + countryconfigDirName)
|
|
262
|
-
console.log(' git init')
|
|
263
|
-
console.log(' npm install\n')
|
|
264
|
-
console.log('To get started with the infrastructure:\n')
|
|
265
|
-
console.log(' cd ' + infrastructureDirName)
|
|
266
|
-
console.log(' git init\n')
|
|
456
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencrvs/create-countryconfig",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0-beta",
|
|
4
4
|
"description": "Scaffold a new OpenCRVS country configuration",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-countryconfig": "./index.js"
|
|
@@ -17,5 +17,8 @@
|
|
|
17
17
|
"countryconfig",
|
|
18
18
|
"create"
|
|
19
19
|
],
|
|
20
|
-
"license": "MPL-2.0"
|
|
20
|
+
"license": "MPL-2.0",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"degit": "^3.6.5"
|
|
23
|
+
}
|
|
21
24
|
}
|