@opencrvs/create-countryconfig 2.0.1 → 2.1.0-beta.2

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 +313 -129
  2. 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
19
+
20
+ const INFRASTRUCTURE_REPOSITORY = 'opencrvs/infrastructure'
21
+ const CORE_REPOSITORY = 'opencrvs/opencrvs-core'
22
+ const COUNTRYCONFIG_TEMPLATE_REPOSITORY_SUBPATH =
23
+ 'packages/countryconfig-template'
17
24
 
18
- const COUNTRYCONFIG_REPO_URL =
19
- 'https://github.com/opencrvs/opencrvs-countryconfig.git'
20
- const INFRASTRUCTURE_REPO_URL = 'https://github.com/opencrvs/infrastructure.git'
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,38 +90,39 @@ function listReleaseTags(repoUrl) {
80
90
  function getLatestCommonReleaseTag() {
81
91
  const infrastructureTags = new Set(listReleaseTags(INFRASTRUCTURE_REPO_URL))
82
92
  return (
83
- listReleaseTags(COUNTRYCONFIG_REPO_URL).find((tag) =>
84
- infrastructureTags.has(tag)
85
- ) || null
93
+ listReleaseTags(CORE_REPO_URL).find((tag) => infrastructureTags.has(tag)) ||
94
+ null
86
95
  )
87
96
  }
88
97
 
89
98
  /**
90
- * A prerelease-shaped own version (e.g. "2.1.0-rc.f5ea803", what npm resolves
91
- * `@next` to - a build published from every push to a branch) never has a
92
- * matching release tag, so it's resolved as a branch instead. Its base
93
- * version (stripped of the "-rc.<sha>" suffix) tells apart two different
94
- * situations: an RC for a version already being stabilized on its own
95
- * "release/X.Y.Z" branch (e.g. "2.0.1-rc.*" while a patch release is in
96
- * progress) versus an RC for a version that hasn't been branched off yet and
97
- * only exists on develop (e.g. "2.1.0-rc.*" while that release branch hasn't
98
- * been cut). Scaffold from the release branch when it exists in both
99
- * repositories, otherwise fall back to develop.
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.
100
102
  *
101
- * Otherwise, the own "X.Y.Z" version - whether resolved via npm's `latest`
102
- * dist-tag (bare invocation) or an explicit `@X.Y.Z` pin - scaffolds from the
103
- * matching "vX.Y.Z" tag when it exists in both repositories. If it doesn't
104
- * (e.g. `latest` lagging behind the repos, or a pin that predates one repo's
105
- * tagging), fall back to the highest release tag common to both, rather than
106
- * a mismatched pairing of one tagged repo at that version and another repo
107
- * at a different release. Exits with an error if no matching release tag
108
- * exists in both repositories.
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.
109
112
  */
110
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
+
111
122
  if (version.includes('-')) {
112
123
  const releaseBranch = 'release/' + version.split('-')[0]
113
124
  if (
114
- branchExists(COUNTRYCONFIG_REPO_URL, releaseBranch) &&
125
+ branchExists(CORE_REPO_URL, releaseBranch) &&
115
126
  branchExists(INFRASTRUCTURE_REPO_URL, releaseBranch)
116
127
  ) {
117
128
  return releaseBranch
@@ -119,19 +130,11 @@ function resolveRef() {
119
130
  return 'develop'
120
131
  }
121
132
 
122
- const tag = 'v' + version
123
- if (
124
- tagExists(COUNTRYCONFIG_REPO_URL, tag) &&
125
- tagExists(INFRASTRUCTURE_REPO_URL, tag)
126
- ) {
127
- return tag
128
- }
129
-
130
133
  const latestCommonTag = getLatestCommonReleaseTag()
131
134
  if (latestCommonTag) {
132
135
  console.warn(
133
136
  '\nWarning: tag "' +
134
- tag +
137
+ versionTag +
135
138
  '" was not found in both repositories; falling back to the latest ' +
136
139
  'available release, ' +
137
140
  latestCommonTag +
@@ -141,126 +144,307 @@ function resolveRef() {
141
144
  }
142
145
 
143
146
  console.error(
144
- '\nError: no matching release tag was found in both the country config and ' +
147
+ '\nError: no matching release tag was found in both the core and ' +
145
148
  'infrastructure repositories.'
146
149
  )
147
150
  process.exit(1)
148
151
  }
149
152
 
150
- function cloneRepository(repoUrl, ref, targetDir) {
151
- execSync(
152
- 'git clone --depth 1 --branch ' + ref + ' ' + repoUrl + ' ' + targetDir,
153
- { stdio: 'inherit' }
154
- )
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
+ *
160
+ * @param {*} targetDir - The target directory where the repository will be cloned.
161
+ */
162
+ async function cloneRepository(
163
+ { repository, repositorySubPath, branch },
164
+ targetDir
165
+ ) {
166
+ const repositoryPath = joinValues([repository, repositorySubPath], '/')
167
+ const fullPath = joinValues([repositoryPath, branch], '#')
168
+
169
+ console.log(`Cloning repository from ${fullPath} to ${targetDir}...`)
170
+
171
+ const emitter = degit(fullPath, {
172
+ mode: 'git'
173
+ })
174
+
175
+ await emitter.clone(targetDir)
176
+ console.log(`Copied files from ${fullPath} to ${targetDir} succesfully.`)
155
177
  }
156
178
 
157
- const projectName = process.argv[2]
179
+ function ensureTargetDirectoryDoesNotExist(directoryName) {
180
+ const targetDirectoryPath = path.resolve(process.cwd(), directoryName)
158
181
 
159
- if (!projectName) {
160
- console.error(
161
- 'Please specify a project name:\n\n npm create @opencrvs/countryconfig <project-name>\n'
162
- )
163
- process.exit(1)
182
+ if (fs.existsSync(targetDirectoryPath)) {
183
+ console.error(
184
+ 'Error: Directory already exists in path "' + targetDirectoryPath + '".'
185
+ )
186
+ process.exit(1)
187
+ }
164
188
  }
165
189
 
166
- const countryconfigDirName = projectName + '-countryconfig'
167
- const infrastructureDirName = projectName + '-infrastructure'
190
+ /**
191
+ * Asks whether to enable telemetry. Defaults to yes, and answers yes without
192
+ * prompting when not attached to a terminal (e.g. non-interactive scaffolding).
193
+ */
194
+ async function promptEnableTelemetry() {
195
+ if (!process.stdin.isTTY) {
196
+ return true
197
+ }
168
198
 
169
- const countryconfigTargetDir = path.resolve(process.cwd(), countryconfigDirName)
170
- const infrastructureTargetDir = path.resolve(
171
- process.cwd(),
172
- infrastructureDirName
173
- )
199
+ const rl = readline.createInterface({
200
+ input: process.stdin,
201
+ output: process.stdout
202
+ })
203
+ try {
204
+ const answer = (
205
+ await rl.question(
206
+ '\nEnable anonymous usage telemetry to help improve OpenCRVS? Only ' +
207
+ 'aggregate metrics are shared — no personal or protected data. [Y/n] '
208
+ )
209
+ )
210
+ .trim()
211
+ .toLowerCase()
212
+ return answer === '' || answer === 'y' || answer === 'yes'
213
+ } finally {
214
+ rl.close()
215
+ }
216
+ }
174
217
 
175
- if (fs.existsSync(countryconfigTargetDir)) {
176
- console.error(
177
- 'Error: Directory "' + countryconfigDirName + '" already exists.'
218
+ /**
219
+ * Prompts for a single line of input, re-asking until `validate` accepts the
220
+ * trimmed answer. `validate` returns an error message string when the answer is
221
+ * invalid, or a falsy value when it is accepted. Exits when not attached to a
222
+ * terminal, since a mandatory value cannot be gathered non-interactively.
223
+ */
224
+ async function promptRequired(question, validate) {
225
+ if (!process.stdin.isTTY) {
226
+ console.error(
227
+ '\nError: interactive input is required to set the organisation name and country code.'
228
+ )
229
+ process.exit(1)
230
+ }
231
+
232
+ const rl = readline.createInterface({
233
+ input: process.stdin,
234
+ output: process.stdout
235
+ })
236
+ try {
237
+ while (true) {
238
+ const answer = (await rl.question(question)).trim()
239
+ const error = validate(answer)
240
+ if (!error) {
241
+ return answer
242
+ }
243
+ console.error(error)
244
+ }
245
+ } finally {
246
+ rl.close()
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Prompts for the organisation name reported with telemetry. Mandatory.
252
+ */
253
+ function promptOrganisation() {
254
+ return promptRequired('\nOrganisation running this instance: ', (answer) =>
255
+ answer === '' ? 'Please enter an organisation name.' : undefined
178
256
  )
179
- process.exit(1)
180
257
  }
181
258
 
182
- if (fs.existsSync(infrastructureTargetDir)) {
183
- console.error(
184
- 'Error: Directory "' + infrastructureDirName + '" already exists.'
259
+ /**
260
+ * Prompts for the alpha-3 ISO country code reported with telemetry, re-asking
261
+ * until a valid three-letter code is given. Mandatory.
262
+ */
263
+ async function promptCountryCode() {
264
+ const answer = await promptRequired(
265
+ '\nAlpha-3 ISO country code of this instance (e.g. "GBR"): ',
266
+ (value) =>
267
+ /^[A-Za-z]{3}$/.test(value)
268
+ ? undefined
269
+ : 'Please enter a three-letter alpha-3 ISO country code (e.g. "GBR").'
185
270
  )
186
- process.exit(1)
271
+ return answer.toUpperCase()
187
272
  }
188
273
 
189
- const ref = resolveRef()
274
+ /**
275
+ * Flips the `TELEMETRY_ENABLED` env var default in the cloned country config's
276
+ * environment to `true`. The template ships it defaulting to `false`.
277
+ */
278
+ function enableTelemetryInEnvironment(targetPath) {
279
+ const environmentPath = path.join(targetPath, 'src', 'environment.ts')
280
+ if (!fs.existsSync(environmentPath)) {
281
+ console.warn(
282
+ '\nWarning: could not find src/environment.ts; telemetry default not changed.'
283
+ )
284
+ return
285
+ }
286
+
287
+ const original = fs.readFileSync(environmentPath, 'utf-8')
288
+ const updated = original.replace(
289
+ /(TELEMETRY_ENABLED:\s*bool\(\{[\s\S]*?default:\s*)false/,
290
+ '$1true'
291
+ )
190
292
 
191
- console.log(
192
- '\nScaffolding OpenCRVS country config in ./' + countryconfigDirName + '...\n'
193
- )
293
+ if (updated === original) {
294
+ console.warn(
295
+ '\nWarning: could not update the TELEMETRY_ENABLED default in src/environment.ts.'
296
+ )
297
+ return
298
+ }
194
299
 
195
- try {
196
- cloneRepository(COUNTRYCONFIG_REPO_URL, ref, countryconfigDirName)
197
- } catch (err) {
198
- console.error('Failed to clone the country config repository:', err.message)
199
- process.exit(1)
300
+ fs.writeFileSync(environmentPath, updated)
301
+ console.log('\nTelemetry enabled (TELEMETRY_ENABLED now defaults to true).')
200
302
  }
201
303
 
202
- try {
203
- fs.rmSync(path.join(countryconfigTargetDir, '.git'), {
204
- recursive: true,
205
- force: true
206
- })
207
- } catch (err) {
208
- console.error(
209
- 'Failed to remove .git directory from country config:',
210
- err.message
304
+ /**
305
+ * Replaces the string `default` of an envalid `str({ ... })` field in the
306
+ * cloned country config's environment. Returns the updated source, or the
307
+ * original source (with a warning) when the field could not be located.
308
+ */
309
+ function setEnvironmentStringDefault(source, key, value) {
310
+ const pattern = new RegExp(
311
+ `(${key}:\\s*str\\(\\{[\\s\\S]*?default:\\s*)'[^']*'`
211
312
  )
212
- process.exit(1)
313
+ // Escape for a single-quoted TS string literal, and use a function replacer
314
+ // so `$` in the value is not treated as a replacement pattern.
315
+ const literal = "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"
316
+ const updated = source.replace(pattern, (_, prefix) => prefix + literal)
317
+
318
+ if (updated === source) {
319
+ console.warn(
320
+ `\nWarning: could not update the ${key} default in src/environment.ts.`
321
+ )
322
+ }
323
+
324
+ return updated
325
+ }
326
+
327
+ /**
328
+ * Writes the given organisation name and alpha-3 country code as the defaults
329
+ * for the `ORGANISATION` and `COUNTRY_CODE` env vars in the cloned country
330
+ * config's environment.
331
+ */
332
+ function setTelemetryIdentityInEnvironment(
333
+ targetPath,
334
+ { organisation, countryCode }
335
+ ) {
336
+ const environmentPath = path.join(targetPath, 'src', 'environment.ts')
337
+ if (!fs.existsSync(environmentPath)) {
338
+ console.warn(
339
+ '\nWarning: could not find src/environment.ts; organisation and country code defaults not changed.'
340
+ )
341
+ return
342
+ }
343
+
344
+ let source = fs.readFileSync(environmentPath, 'utf-8')
345
+ source = setEnvironmentStringDefault(source, 'ORGANISATION', organisation)
346
+ source = setEnvironmentStringDefault(source, 'COUNTRY_CODE', countryCode)
347
+ fs.writeFileSync(environmentPath, source)
213
348
  }
214
349
 
215
- const pkgPath = path.join(countryconfigTargetDir, 'package.json')
216
- if (fs.existsSync(pkgPath)) {
350
+ function updatePackageJsonName(targetPath, newName) {
351
+ console.log('\nUpdating package.json with project name: ' + newName + '\n')
352
+
353
+ const pkgPath = path.join(targetPath, 'package.json')
354
+ if (fs.existsSync(pkgPath)) {
355
+ try {
356
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
357
+ pkg.name = newName
358
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
359
+ } catch (err) {
360
+ console.error('\nFailed to update package.json:', err.message)
361
+ process.exit(1)
362
+ }
363
+ } else {
364
+ console.warn(
365
+ '\nWarning: No package.json found in the targetPath: ' +
366
+ targetPath +
367
+ '. Project name was not updated.'
368
+ )
369
+ }
370
+ }
371
+
372
+ async function main() {
373
+ const projectName = process.argv[2]
374
+
375
+ if (!projectName) {
376
+ console.error(
377
+ 'Please specify a project name:\n\n npm create @opencrvs/countryconfig <project-name>\n'
378
+ )
379
+ process.exit(1)
380
+ }
381
+
382
+ const countryconfigDirName = projectName + '-countryconfig'
383
+ const countryconfigTargetPath = path.resolve(
384
+ process.cwd(),
385
+ countryconfigDirName
386
+ )
387
+ const infrastructureDirName = projectName + '-infrastructure'
388
+ const infrastructureTargetPath = path.resolve(
389
+ process.cwd(),
390
+ infrastructureDirName
391
+ )
392
+
393
+ ensureTargetDirectoryDoesNotExist(countryconfigDirName)
394
+ ensureTargetDirectoryDoesNotExist(infrastructureDirName)
395
+
396
+ const ref = resolveRef()
397
+
398
+ // Gather all answers up front so the operator isn't interrupted mid-clone.
399
+ const organisation = await promptOrganisation()
400
+ const countryCode = await promptCountryCode()
401
+ const telemetryEnabled = await promptEnableTelemetry()
402
+
217
403
  try {
218
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
219
- pkg.name = countryconfigDirName
220
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
404
+ await cloneRepository(
405
+ {
406
+ repository: CORE_REPOSITORY,
407
+ repositorySubPath: COUNTRYCONFIG_TEMPLATE_REPOSITORY_SUBPATH,
408
+ branch: ref
409
+ },
410
+ countryconfigTargetPath
411
+ )
221
412
  } catch (err) {
222
- console.error('Failed to update package.json:', err.message)
413
+ console.error('\nFailed to clone country config template:', err.message)
223
414
  process.exit(1)
224
415
  }
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
416
 
231
- console.log(
232
- '\nScaffolding OpenCRVS infrastructure in ./' +
233
- infrastructureDirName +
234
- '...\n'
235
- )
417
+ updatePackageJsonName(countryconfigTargetPath, countryconfigDirName)
236
418
 
237
- try {
238
- cloneRepository(INFRASTRUCTURE_REPO_URL, ref, infrastructureDirName)
239
- } catch (err) {
240
- console.error('Failed to clone the infrastructure repository:', err.message)
241
- process.exit(1)
242
- }
419
+ try {
420
+ await cloneRepository(
421
+ { repository: INFRASTRUCTURE_REPOSITORY, branch: ref },
422
+ infrastructureTargetPath
423
+ )
424
+ } catch (err) {
425
+ console.error('Failed to clone the infrastructure repository:', err.message)
426
+ process.exit(1)
427
+ }
243
428
 
244
- try {
245
- fs.rmSync(path.join(infrastructureTargetDir, '.git'), {
246
- recursive: true,
247
- force: true
429
+ setTelemetryIdentityInEnvironment(countryconfigTargetPath, {
430
+ organisation,
431
+ countryCode
248
432
  })
249
- } catch (err) {
250
- console.error(
251
- 'Failed to remove .git directory from infrastructure:',
252
- err.message
253
- )
254
- process.exit(1)
433
+
434
+ if (telemetryEnabled) {
435
+ enableTelemetryInEnvironment(countryconfigTargetPath)
436
+ }
437
+
438
+ console.log('\nDone! Your project has been set up in two directories:\n')
439
+ console.log(' ./' + countryconfigDirName + ' -- country configuration')
440
+ console.log(' ./' + infrastructureDirName + ' -- server infrastructure\n')
441
+ console.log('To get started with the country config:\n')
442
+ console.log(' cd ' + countryconfigDirName)
443
+ console.log(' git init')
444
+ console.log(' tilt up\n')
445
+ console.log('To get started with the infrastructure:\n')
446
+ console.log(' cd ' + infrastructureDirName)
447
+ console.log(' git init\n')
255
448
  }
256
449
 
257
- console.log('\nDone! Your project has been set up in two directories:\n')
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')
450
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencrvs/create-countryconfig",
3
- "version": "2.0.1",
3
+ "version": "2.1.0-beta.2",
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
  }