@opencrvs/create-countryconfig 2.1.0-rc.2ec6bd5 → 2.1.0-rc.312fbd1

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.
Files changed (2) hide show
  1. package/index.js +441 -59
  2. package/package.json +5 -2
package/index.js CHANGED
@@ -11,93 +11,475 @@
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 COUNTRYCONFIG_REPO_URL =
19
- 'https://github.com/opencrvs/opencrvs-countryconfig.git'
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'
20
26
  const INFRASTRUCTURE_REPO_URL =
21
- 'https://github.com/opencrvs/infrastructure.git'
27
+ 'https://github.com/' + INFRASTRUCTURE_REPOSITORY + '.git'
28
+
29
+ const { version } = JSON.parse(
30
+ fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8')
31
+ )
32
+
33
+ function joinValues(values, separator) {
34
+ return values
35
+ .filter((value) => !!value)
36
+ .join(separator)
37
+ .trim()
38
+ }
39
+
40
+ function tagExists(repoUrl, tag) {
41
+ try {
42
+ execSync(
43
+ 'git ls-remote --exit-code --tags ' + repoUrl + ' refs/tags/' + tag,
44
+ { stdio: 'pipe' }
45
+ )
46
+ return true
47
+ } catch (err) {
48
+ return false
49
+ }
50
+ }
51
+
52
+ function branchExists(repoUrl, branch) {
53
+ try {
54
+ execSync(
55
+ 'git ls-remote --exit-code --heads ' + repoUrl + ' refs/heads/' + branch,
56
+ { stdio: 'pipe' }
57
+ )
58
+ return true
59
+ } catch (err) {
60
+ return false
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Release tags (e.g. "v2.1.0"), highest first. Delegates the version-aware
66
+ * ordering to git itself rather than hand-parsing semver, then filters down
67
+ * to strict "vX.Y.Z" tags - `--sort=-version:refname` alone still leaves in
68
+ * non-release refs (e.g. "vtesting", "v2.0.0-beta") and peeled annotated-tag
69
+ * lines ("refs/tags/v2.0.0^{}").
70
+ */
71
+ function listReleaseTags(repoUrl) {
72
+ const output = execSync(
73
+ 'git ls-remote --tags --sort=-version:refname ' + repoUrl,
74
+ { encoding: 'utf-8' }
75
+ )
76
+
77
+ return output
78
+ .split('\n')
79
+ .map((line) => line.split('\t')[1])
80
+ .filter(Boolean)
81
+ .map((ref) => ref.replace('refs/tags/', ''))
82
+ .filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag))
83
+ }
84
+
85
+ /**
86
+ * The highest release tag present in *both* repositories - used as the
87
+ * fallback when the version-specific tag can't be found in one or both, so
88
+ * scaffolding still lands on a real, matched release rather than develop.
89
+ */
90
+ function getLatestCommonReleaseTag() {
91
+ const infrastructureTags = new Set(listReleaseTags(INFRASTRUCTURE_REPO_URL))
92
+ return (
93
+ listReleaseTags(CORE_REPO_URL).find((tag) => infrastructureTags.has(tag)) ||
94
+ null
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Resolves the ref that both repositories are cloned from. A single ref
100
+ * clones both, so every candidate must exist in *both* - a tag present in
101
+ * only one of them can't be used.
102
+ *
103
+ * The exact "v<version>" tag wins whenever it exists: it pins a commit, so
104
+ * a release, an explicit `@X.Y.Z` pin, or a blessed prerelease (npm `@beta`,
105
+ * published from that tag) reproduces however late it's scaffolded.
106
+ *
107
+ * Otherwise a prerelease falls back to a branch, since a rolling release
108
+ * candidate (npm `@next`) is never tagged: "release/X.Y.Z" of the base
109
+ * version when that release has been cut, otherwise develop. A release
110
+ * falls back to the highest tag common to both repositories - never a
111
+ * mismatched pairing of the two at different releases - or errors.
112
+ */
113
+ function resolveRef() {
114
+ const versionTag = 'v' + version
115
+ if (
116
+ tagExists(CORE_REPO_URL, versionTag) &&
117
+ tagExists(INFRASTRUCTURE_REPO_URL, versionTag)
118
+ ) {
119
+ return versionTag
120
+ }
121
+
122
+ if (version.includes('-')) {
123
+ const releaseBranch = 'release/' + version.split('-')[0]
124
+ if (
125
+ branchExists(CORE_REPO_URL, releaseBranch) &&
126
+ branchExists(INFRASTRUCTURE_REPO_URL, releaseBranch)
127
+ ) {
128
+ return releaseBranch
129
+ }
130
+ return 'develop'
131
+ }
22
132
 
23
- const projectName = process.argv[2]
133
+ const latestCommonTag = getLatestCommonReleaseTag()
134
+ if (latestCommonTag) {
135
+ console.warn(
136
+ '\nWarning: tag "' +
137
+ versionTag +
138
+ '" was not found in both repositories; falling back to the latest ' +
139
+ 'available release, ' +
140
+ latestCommonTag +
141
+ '.'
142
+ )
143
+ return latestCommonTag
144
+ }
24
145
 
25
- if (!projectName) {
26
146
  console.error(
27
- 'Please specify a project name:\n\n npm create @opencrvs/countryconfig <project-name>\n'
147
+ '\nError: no matching release tag was found in both the core and ' +
148
+ 'infrastructure repositories.'
28
149
  )
29
150
  process.exit(1)
30
151
  }
31
152
 
32
- const countryconfigDirName = projectName + '-countryconfig'
33
- const infrastructureDirName = projectName + '-infrastructure'
153
+ /**
154
+ * Clones a repository from GitHub to a target directory.
155
+ *
156
+ * @param {*} param0 repository - The repository to clone (e.g., 'opencrvs/opencrvs-core').
157
+ * @param {*} param0 repositorySubPath - The subpath within the repository to clone (optional). Otherwise the entire repository will be cloned.
158
+ * @param {*} param0 branch - The branch to clone (optional). Defaults to the default branch if not specified.
159
+ * @param {*} param0 keepHistory - Keep the repository's git history instead of degit's usual history-free copy (optional, defaults to false). The cloned "origin" remote is replaced with "upstream", leaving the directory ready for the user to add their own fork/repo as "origin". Not supported together with repositorySubPath, since a plain git clone can't fetch a single subdirectory.
160
+ *
161
+ * @param {*} targetDir - The target directory where the repository will be cloned.
162
+ */
163
+ async function cloneRepository(
164
+ { repository, repositorySubPath, branch, keepHistory = false },
165
+ targetDir
166
+ ) {
167
+ if (keepHistory && repositorySubPath) {
168
+ throw new Error(
169
+ 'cloneRepository: keepHistory is not supported together with repositorySubPath.'
170
+ )
171
+ }
34
172
 
35
- const countryconfigTargetDir = path.resolve(process.cwd(), countryconfigDirName)
36
- const infrastructureTargetDir = path.resolve(process.cwd(), infrastructureDirName)
173
+ if (keepHistory) {
174
+ const repoUrl = `https://github.com/${repository}.git`
175
+ console.log(
176
+ `Cloning repository from ${repoUrl}#${branch} to ${targetDir}...`
177
+ )
37
178
 
38
- if (fs.existsSync(countryconfigTargetDir)) {
39
- console.error('Error: Directory "' + countryconfigDirName + '" already exists.')
40
- process.exit(1)
179
+ execSync('git clone --branch ' + branch + ' ' + repoUrl + ' ' + targetDir, {
180
+ stdio: 'inherit'
181
+ })
182
+
183
+ console.log(
184
+ `Copied files from ${repoUrl}#${branch} to ${targetDir} succesfully.`
185
+ )
186
+
187
+ console.log(`Replacing 'origin' remote with 'upstream' in ${targetDir}...`)
188
+ execSync('git remote remove origin', { cwd: targetDir, stdio: 'inherit' })
189
+ execSync('git remote add upstream ' + repoUrl, {
190
+ cwd: targetDir,
191
+ stdio: 'inherit'
192
+ })
193
+ return
194
+ }
195
+
196
+ const repositoryPath = joinValues([repository, repositorySubPath], '/')
197
+ const fullPath = joinValues([repositoryPath, branch], '#')
198
+
199
+ console.log(`Cloning repository from ${fullPath} to ${targetDir}...`)
200
+
201
+ const emitter = degit(fullPath, {
202
+ mode: 'git'
203
+ })
204
+
205
+ await emitter.clone(targetDir)
206
+ console.log(`Copied files from ${fullPath} to ${targetDir} succesfully.`)
41
207
  }
42
208
 
43
- if (fs.existsSync(infrastructureTargetDir)) {
44
- console.error('Error: Directory "' + infrastructureDirName + '" already exists.')
45
- process.exit(1)
209
+ function ensureTargetDirectoryDoesNotExist(directoryName) {
210
+ const targetDirectoryPath = path.resolve(process.cwd(), directoryName)
211
+
212
+ if (fs.existsSync(targetDirectoryPath)) {
213
+ console.error(
214
+ 'Error: Directory already exists in path "' + targetDirectoryPath + '".'
215
+ )
216
+ process.exit(1)
217
+ }
46
218
  }
47
219
 
48
- console.log('\nScaffolding OpenCRVS country config in ./' + countryconfigDirName + '...\n')
220
+ /**
221
+ * Asks whether to enable telemetry. Defaults to yes, and answers yes without
222
+ * prompting when not attached to a terminal (e.g. non-interactive scaffolding).
223
+ */
224
+ async function promptEnableTelemetry() {
225
+ if (!process.stdin.isTTY) {
226
+ return true
227
+ }
49
228
 
50
- try {
51
- execSync('git clone --depth 1 ' + COUNTRYCONFIG_REPO_URL + ' ' + countryconfigDirName, { stdio: 'inherit' })
52
- } catch (err) {
53
- console.error('Failed to clone the country config repository:', err.message)
54
- process.exit(1)
229
+ const rl = readline.createInterface({
230
+ input: process.stdin,
231
+ output: process.stdout
232
+ })
233
+ try {
234
+ const answer = (
235
+ await rl.question(
236
+ '\nEnable anonymous usage telemetry to help improve OpenCRVS? Only ' +
237
+ 'aggregate metrics are shared — no personal or protected data. [Y/n] '
238
+ )
239
+ )
240
+ .trim()
241
+ .toLowerCase()
242
+ return answer === '' || answer === 'y' || answer === 'yes'
243
+ } finally {
244
+ rl.close()
245
+ }
55
246
  }
56
247
 
57
- try {
58
- fs.rmSync(path.join(countryconfigTargetDir, '.git'), { recursive: true, force: true })
59
- } catch (err) {
60
- console.error('Failed to remove .git directory from country config:', err.message)
61
- process.exit(1)
248
+ /**
249
+ * Prompts for a single line of input, re-asking until `validate` accepts the
250
+ * trimmed answer. `validate` returns an error message string when the answer is
251
+ * invalid, or a falsy value when it is accepted. Exits when not attached to a
252
+ * terminal, since a mandatory value cannot be gathered non-interactively.
253
+ */
254
+ async function promptRequired(question, validate) {
255
+ if (!process.stdin.isTTY) {
256
+ console.error(
257
+ '\nError: interactive input is required to set the organisation name and country code.'
258
+ )
259
+ process.exit(1)
260
+ }
261
+
262
+ const rl = readline.createInterface({
263
+ input: process.stdin,
264
+ output: process.stdout
265
+ })
266
+ try {
267
+ while (true) {
268
+ const answer = (await rl.question(question)).trim()
269
+ const error = validate(answer)
270
+ if (!error) {
271
+ return answer
272
+ }
273
+ console.error(error)
274
+ }
275
+ } finally {
276
+ rl.close()
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Prompts for the organisation name reported with telemetry. Mandatory.
282
+ */
283
+ function promptOrganisation() {
284
+ return promptRequired('\nOrganisation running this instance: ', (answer) =>
285
+ answer === '' ? 'Please enter an organisation name.' : undefined
286
+ )
287
+ }
288
+
289
+ /**
290
+ * Prompts for the alpha-3 ISO country code reported with telemetry, re-asking
291
+ * until a valid three-letter code is given. Mandatory.
292
+ */
293
+ async function promptCountryCode() {
294
+ const answer = await promptRequired(
295
+ '\nAlpha-3 ISO country code of this instance (e.g. "GBR"): ',
296
+ (value) =>
297
+ /^[A-Za-z]{3}$/.test(value)
298
+ ? undefined
299
+ : 'Please enter a three-letter alpha-3 ISO country code (e.g. "GBR").'
300
+ )
301
+ return answer.toUpperCase()
302
+ }
303
+
304
+ /**
305
+ * Flips the `TELEMETRY_ENABLED` env var default in the cloned country config's
306
+ * environment to `true`. The template ships it defaulting to `false`.
307
+ */
308
+ function enableTelemetryInEnvironment(targetPath) {
309
+ const environmentPath = path.join(targetPath, 'src', 'environment.ts')
310
+ if (!fs.existsSync(environmentPath)) {
311
+ console.warn(
312
+ '\nWarning: could not find src/environment.ts; telemetry default not changed.'
313
+ )
314
+ return
315
+ }
316
+
317
+ const original = fs.readFileSync(environmentPath, 'utf-8')
318
+ const updated = original.replace(
319
+ /(TELEMETRY_ENABLED:\s*bool\(\{[\s\S]*?default:\s*)false/,
320
+ '$1true'
321
+ )
322
+
323
+ if (updated === original) {
324
+ console.warn(
325
+ '\nWarning: could not update the TELEMETRY_ENABLED default in src/environment.ts.'
326
+ )
327
+ return
328
+ }
329
+
330
+ fs.writeFileSync(environmentPath, updated)
331
+ console.log('\nTelemetry enabled (TELEMETRY_ENABLED now defaults to true).')
332
+ }
333
+
334
+ /**
335
+ * Replaces the string `default` of an envalid `str({ ... })` field in the
336
+ * cloned country config's environment. Returns the updated source, or the
337
+ * original source (with a warning) when the field could not be located.
338
+ */
339
+ function setEnvironmentStringDefault(source, key, value) {
340
+ const pattern = new RegExp(
341
+ `(${key}:\\s*str\\(\\{[\\s\\S]*?default:\\s*)'[^']*'`
342
+ )
343
+ // Escape for a single-quoted TS string literal, and use a function replacer
344
+ // so `$` in the value is not treated as a replacement pattern.
345
+ const literal = "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"
346
+ const updated = source.replace(pattern, (_, prefix) => prefix + literal)
347
+
348
+ if (updated === source) {
349
+ console.warn(
350
+ `\nWarning: could not update the ${key} default in src/environment.ts.`
351
+ )
352
+ }
353
+
354
+ return updated
355
+ }
356
+
357
+ /**
358
+ * Writes the given organisation name and alpha-3 country code as the defaults
359
+ * for the `ORGANISATION` and `COUNTRY_CODE` env vars in the cloned country
360
+ * config's environment.
361
+ */
362
+ function setTelemetryIdentityInEnvironment(
363
+ targetPath,
364
+ { organisation, countryCode }
365
+ ) {
366
+ const environmentPath = path.join(targetPath, 'src', 'environment.ts')
367
+ if (!fs.existsSync(environmentPath)) {
368
+ console.warn(
369
+ '\nWarning: could not find src/environment.ts; organisation and country code defaults not changed.'
370
+ )
371
+ return
372
+ }
373
+
374
+ let source = fs.readFileSync(environmentPath, 'utf-8')
375
+ source = setEnvironmentStringDefault(source, 'ORGANISATION', organisation)
376
+ source = setEnvironmentStringDefault(source, 'COUNTRY_CODE', countryCode)
377
+ fs.writeFileSync(environmentPath, source)
378
+ }
379
+
380
+ function updatePackageJsonName(targetPath, newName) {
381
+ console.log('\nUpdating package.json with project name: ' + newName + '\n')
382
+
383
+ const pkgPath = path.join(targetPath, 'package.json')
384
+ if (fs.existsSync(pkgPath)) {
385
+ try {
386
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
387
+ pkg.name = newName
388
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
389
+ } catch (err) {
390
+ console.error('\nFailed to update package.json:', err.message)
391
+ process.exit(1)
392
+ }
393
+ } else {
394
+ console.warn(
395
+ '\nWarning: No package.json found in the targetPath: ' +
396
+ targetPath +
397
+ '. Project name was not updated.'
398
+ )
399
+ }
62
400
  }
63
401
 
64
- const pkgPath = path.join(countryconfigTargetDir, 'package.json')
65
- if (fs.existsSync(pkgPath)) {
402
+ async function main() {
403
+ const projectName = process.argv[2]
404
+
405
+ if (!projectName) {
406
+ console.error(
407
+ 'Please specify a project name:\n\n npm create @opencrvs/countryconfig <project-name>\n'
408
+ )
409
+ process.exit(1)
410
+ }
411
+
412
+ const countryconfigDirName = projectName + '-countryconfig'
413
+ const countryconfigTargetPath = path.resolve(
414
+ process.cwd(),
415
+ countryconfigDirName
416
+ )
417
+ const infrastructureDirName = projectName + '-infrastructure'
418
+ const infrastructureTargetPath = path.resolve(
419
+ process.cwd(),
420
+ infrastructureDirName
421
+ )
422
+
423
+ ensureTargetDirectoryDoesNotExist(countryconfigDirName)
424
+ ensureTargetDirectoryDoesNotExist(infrastructureDirName)
425
+
426
+ const ref = resolveRef()
427
+
428
+ // Gather all answers up front so the operator isn't interrupted mid-clone.
429
+ const organisation = await promptOrganisation()
430
+ const countryCode = await promptCountryCode()
431
+ const telemetryEnabled = await promptEnableTelemetry()
432
+
66
433
  try {
67
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
68
- pkg.name = countryconfigDirName
69
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
434
+ await cloneRepository(
435
+ {
436
+ repository: CORE_REPOSITORY,
437
+ repositorySubPath: COUNTRYCONFIG_TEMPLATE_REPOSITORY_SUBPATH,
438
+ branch: ref
439
+ },
440
+ countryconfigTargetPath
441
+ )
70
442
  } catch (err) {
71
- console.error('Failed to update package.json:', err.message)
443
+ console.error('\nFailed to clone country config template:', err.message)
72
444
  process.exit(1)
73
445
  }
74
- } else {
75
- console.warn('Warning: No package.json found in the cloned country config repository. Project name was not updated.')
76
- }
77
446
 
78
- console.log('\nScaffolding OpenCRVS infrastructure in ./' + infrastructureDirName + '...\n')
447
+ updatePackageJsonName(countryconfigTargetPath, countryconfigDirName)
79
448
 
80
- try {
81
- execSync('git clone --depth 1 ' + INFRASTRUCTURE_REPO_URL + ' ' + infrastructureDirName, { stdio: 'inherit' })
82
- } catch (err) {
83
- console.error('Failed to clone the infrastructure repository:', err.message)
84
- process.exit(1)
85
- }
449
+ try {
450
+ await cloneRepository(
451
+ { repository: INFRASTRUCTURE_REPOSITORY, branch: ref, keepHistory: true },
452
+ infrastructureTargetPath
453
+ )
454
+ } catch (err) {
455
+ console.error('Failed to clone the infrastructure repository:', err.message)
456
+ process.exit(1)
457
+ }
86
458
 
87
- try {
88
- fs.rmSync(path.join(infrastructureTargetDir, '.git'), { recursive: true, force: true })
89
- } catch (err) {
90
- console.error('Failed to remove .git directory from infrastructure:', err.message)
91
- process.exit(1)
459
+ setTelemetryIdentityInEnvironment(countryconfigTargetPath, {
460
+ organisation,
461
+ countryCode
462
+ })
463
+
464
+ if (telemetryEnabled) {
465
+ enableTelemetryInEnvironment(countryconfigTargetPath)
466
+ }
467
+
468
+ console.log('\nDone! Your project has been set up in two directories:\n')
469
+ console.log(' ./' + countryconfigDirName + ' -- country configuration')
470
+ console.log(' ./' + infrastructureDirName + ' -- server infrastructure\n')
471
+ console.log('To get started with the country config:\n')
472
+ console.log(' cd ' + countryconfigDirName)
473
+ console.log(' git init')
474
+ console.log(' tilt up\n')
475
+ console.log('To get started with the infrastructure:\n')
476
+ console.log(' cd ' + infrastructureDirName)
477
+ console.log(' git remote add origin <your-infrastructure-repo-url>')
478
+ console.log(' git push -u origin ' + ref + '\n')
479
+ console.log(
480
+ 'The "upstream" remote points at the official infrastructure repository, ' +
481
+ 'so you can pull future releases with `git fetch upstream`.\n'
482
+ )
92
483
  }
93
484
 
94
- console.log('\nDone! Your project has been set up in two directories:\n')
95
- console.log(' ./' + countryconfigDirName + ' -- country configuration')
96
- console.log(' ./' + infrastructureDirName + ' -- server infrastructure\n')
97
- console.log('To get started with the country config:\n')
98
- console.log(' cd ' + countryconfigDirName)
99
- console.log(' git init')
100
- console.log(' npm install\n')
101
- console.log('To get started with the infrastructure:\n')
102
- console.log(' cd ' + infrastructureDirName)
103
- console.log(' git init\n')
485
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencrvs/create-countryconfig",
3
- "version": "2.1.0-rc.2ec6bd5",
3
+ "version": "2.1.0-rc.312fbd1",
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
  }