@dhis2/create-app 5.3.1 → 5.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dhis2/create-app",
3
- "version": "5.3.1",
3
+ "version": "5.4.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ const { input, select } = require('@inquirer/prompts')
5
5
  const fg = require('fast-glob')
6
6
  const fs = require('fs-extra')
7
7
  const { default: getPackageManager } = require('./utils/getPackageManager')
8
+ const resolveExternalTemplateSource = require('./utils/resolveExternalTemplateSource')
8
9
 
9
10
  process.on('uncaughtException', (error) => {
10
11
  if (error instanceof Error && error.name === 'ExitPromptError') {
@@ -45,7 +46,8 @@ const commandHandler = {
45
46
  alias: ['ts', 'typeScript'],
46
47
  },
47
48
  template: {
48
- description: 'Which template to use (Basic, With React Router)',
49
+ description:
50
+ 'Which template to use (Basic, With React Router, or GitHub template specifier)',
49
51
  type: 'string',
50
52
  },
51
53
  packageManager: {
@@ -56,10 +58,16 @@ const commandHandler = {
56
58
  },
57
59
  }
58
60
 
59
- const getTemplateDirectory = (templateName) => {
60
- return templateName === 'react-router'
61
- ? templates.templateWithReactRouter
62
- : templates.templateWithList
61
+ const getBuiltInTemplateDirectory = (templateName) => {
62
+ if (templateName === 'basic') {
63
+ return templates.templateWithList
64
+ }
65
+
66
+ if (templateName === 'react-router') {
67
+ return templates.templateWithReactRouter
68
+ }
69
+
70
+ return null
63
71
  }
64
72
 
65
73
  const command = {
@@ -86,7 +94,7 @@ const command = {
86
94
  typeScript: argv.typescript ?? true,
87
95
  packageManager:
88
96
  argv.packageManager ?? getPackageManager() ?? 'pnpm',
89
- templateName: argv.template ?? 'basic',
97
+ templateSource: argv.template ?? 'basic',
90
98
  }
91
99
 
92
100
  if (!useDefauls) {
@@ -106,17 +114,29 @@ const command = {
106
114
  if (argv.template === undefined) {
107
115
  const template = await select({
108
116
  message: 'Select a template',
109
- default: 'ts',
117
+ default: 'basic',
110
118
  choices: [
111
119
  { name: 'Basic Template', value: 'basic' },
112
120
  {
113
121
  name: 'Template with React Router',
114
122
  value: 'react-router',
115
123
  },
124
+ {
125
+ name: 'Custom template from Git',
126
+ value: 'custom-git',
127
+ },
116
128
  ],
117
129
  })
118
130
 
119
- selectedOptions.templateName = template
131
+ if (template === 'custom-git') {
132
+ selectedOptions.templateSource = await input({
133
+ message:
134
+ 'Enter GitHub template specifier (e.g. owner/repo#main)',
135
+ required: true,
136
+ })
137
+ } else {
138
+ selectedOptions.templateSource = template
139
+ }
120
140
  }
121
141
  }
122
142
 
@@ -158,8 +178,30 @@ const command = {
158
178
  }
159
179
 
160
180
  reporter.info('Copying template files')
161
- const templateFiles = getTemplateDirectory(selectedOptions.templateName)
162
- fs.copySync(templateFiles, cwd)
181
+ let resolvedExternalTemplate
182
+ try {
183
+ const builtInTemplatePath = getBuiltInTemplateDirectory(
184
+ selectedOptions.templateSource
185
+ )
186
+
187
+ if (builtInTemplatePath) {
188
+ fs.copySync(builtInTemplatePath, cwd)
189
+ } else {
190
+ resolvedExternalTemplate = await resolveExternalTemplateSource(
191
+ selectedOptions.templateSource
192
+ )
193
+ fs.copySync(resolvedExternalTemplate.templatePath, cwd)
194
+ }
195
+ } catch (error) {
196
+ reporter.error(
197
+ error instanceof Error ? error.message : String(error)
198
+ )
199
+ process.exit(1)
200
+ } finally {
201
+ if (resolvedExternalTemplate) {
202
+ await resolvedExternalTemplate.cleanup()
203
+ }
204
+ }
163
205
 
164
206
  const paths = {
165
207
  base: cwd,
@@ -0,0 +1,162 @@
1
+ const githubHosts = new Set(['github.com', 'www.github.com'])
2
+ const ownerPattern = /^[a-zA-Z0-9_.-]+$/
3
+
4
+ const parseRef = (rawTemplateSource, refPart) => {
5
+ if (refPart === undefined) {
6
+ return null
7
+ }
8
+ if (!refPart) {
9
+ throw new Error(
10
+ `Invalid template source "${rawTemplateSource}". Ref cannot be empty after "#".`
11
+ )
12
+ }
13
+ if (refPart.includes(':')) {
14
+ throw new Error(
15
+ `Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".`
16
+ )
17
+ }
18
+
19
+ return refPart
20
+ }
21
+
22
+ const parseGithubUrlSource = (sourceWithoutRef) => {
23
+ const parsedUrl = new URL(sourceWithoutRef)
24
+ if (!githubHosts.has(parsedUrl.host)) {
25
+ throw new Error(
26
+ `Unsupported template host "${parsedUrl.host}". Only github.com repositories are supported.`
27
+ )
28
+ }
29
+
30
+ const pathParts = parsedUrl.pathname.split('/').filter(Boolean).slice(0, 2)
31
+ if (pathParts.length < 2) {
32
+ throw new Error(
33
+ `Invalid GitHub repository path in "${sourceWithoutRef}". Use "owner/repo".`
34
+ )
35
+ }
36
+
37
+ return {
38
+ owner: pathParts[0],
39
+ repo: pathParts[1],
40
+ }
41
+ }
42
+
43
+ const parseGithubShorthandSource = (rawTemplateSource, sourceWithoutRef) => {
44
+ const separatorIndex = sourceWithoutRef.indexOf('/')
45
+ const hasSingleSeparator =
46
+ separatorIndex > 0 &&
47
+ separatorIndex === sourceWithoutRef.lastIndexOf('/')
48
+ if (!hasSingleSeparator) {
49
+ throw new Error(
50
+ `Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".`
51
+ )
52
+ }
53
+
54
+ const owner = sourceWithoutRef.slice(0, separatorIndex)
55
+ const repo = sourceWithoutRef.slice(separatorIndex + 1)
56
+ if (
57
+ !ownerPattern.test(owner) ||
58
+ !repo ||
59
+ /\s/.test(repo) ||
60
+ repo.includes('/')
61
+ ) {
62
+ throw new Error(
63
+ `Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".`
64
+ )
65
+ }
66
+
67
+ return {
68
+ owner,
69
+ repo,
70
+ }
71
+ }
72
+
73
+ const isValidRefPart = (refPart) => {
74
+ if (refPart === undefined) {
75
+ return true
76
+ }
77
+
78
+ return Boolean(refPart) && !refPart.includes(':')
79
+ }
80
+
81
+ const isValidShorthandSource = (sourceWithoutRef) => {
82
+ const separatorIndex = sourceWithoutRef.indexOf('/')
83
+ const hasSingleSeparator =
84
+ separatorIndex > 0 &&
85
+ separatorIndex === sourceWithoutRef.lastIndexOf('/')
86
+ if (!hasSingleSeparator) {
87
+ return false
88
+ }
89
+
90
+ const owner = sourceWithoutRef.slice(0, separatorIndex)
91
+ const repo = sourceWithoutRef.slice(separatorIndex + 1)
92
+
93
+ return (
94
+ ownerPattern.test(owner) &&
95
+ Boolean(repo) &&
96
+ !/\s/.test(repo) &&
97
+ !repo.includes('/')
98
+ )
99
+ }
100
+
101
+ const parseGitTemplateSpecifier = (templateSource) => {
102
+ const rawTemplateSource = String(templateSource || '').trim()
103
+ if (!rawTemplateSource) {
104
+ throw new Error('Template source cannot be empty.')
105
+ }
106
+
107
+ const [sourceWithoutRef, refPart, ...rest] = rawTemplateSource.split('#')
108
+ if (rest.length > 0) {
109
+ throw new Error(
110
+ `Invalid template source "${rawTemplateSource}". Use at most one "#" to specify a ref.`
111
+ )
112
+ }
113
+
114
+ const ref = parseRef(rawTemplateSource, refPart)
115
+ const sourceInfo = sourceWithoutRef.startsWith('https://')
116
+ ? parseGithubUrlSource(sourceWithoutRef)
117
+ : parseGithubShorthandSource(rawTemplateSource, sourceWithoutRef)
118
+
119
+ const owner = sourceInfo.owner
120
+ let repo = sourceInfo.repo
121
+
122
+ if (repo.endsWith('.git')) {
123
+ repo = repo.slice(0, -4)
124
+ }
125
+
126
+ if (!owner || !repo) {
127
+ throw new Error(
128
+ `Invalid template source "${rawTemplateSource}". Missing GitHub owner or repository name.`
129
+ )
130
+ }
131
+
132
+ return {
133
+ owner,
134
+ repo,
135
+ ref,
136
+ repoUrl: `https://github.com/${owner}/${repo}.git`,
137
+ raw: rawTemplateSource,
138
+ }
139
+ }
140
+
141
+ const isGitTemplateSpecifier = (templateSource) => {
142
+ const rawTemplateSource = String(templateSource || '').trim()
143
+ if (!rawTemplateSource) {
144
+ return false
145
+ }
146
+
147
+ if (rawTemplateSource.startsWith('https://')) {
148
+ return true
149
+ }
150
+
151
+ const [sourceWithoutRef, refPart, ...rest] = rawTemplateSource.split('#')
152
+ if (rest.length > 0 || !isValidRefPart(refPart)) {
153
+ return false
154
+ }
155
+
156
+ return isValidShorthandSource(sourceWithoutRef)
157
+ }
158
+
159
+ module.exports = {
160
+ isGitTemplateSpecifier,
161
+ parseGitTemplateSpecifier,
162
+ }
@@ -0,0 +1,66 @@
1
+ const os = require('node:os')
2
+ const path = require('node:path')
3
+ const { exec } = require('@dhis2/cli-helpers-engine')
4
+ const fs = require('fs-extra')
5
+ const {
6
+ isGitTemplateSpecifier,
7
+ parseGitTemplateSpecifier,
8
+ } = require('./isGitTemplateSpecifier')
9
+ const validateTemplateDirectory = require('./validateTemplateDirectory')
10
+
11
+ const resolveExternalTemplateSource = async (templateSource) => {
12
+ const normalizedTemplateSource = String(templateSource || '').trim()
13
+
14
+ if (!isGitTemplateSpecifier(normalizedTemplateSource)) {
15
+ throw new Error(
16
+ `Unknown template "${normalizedTemplateSource}". Use one of [basic, react-router] or a GitHub template specifier like "owner/repo#ref".`
17
+ )
18
+ }
19
+
20
+ const parsedSpecifier = parseGitTemplateSpecifier(normalizedTemplateSource)
21
+ const tempBase = fs.mkdtempSync(
22
+ path.join(os.tmpdir(), 'd2-create-template-source-')
23
+ )
24
+ const clonedRepoPath = path.join(tempBase, 'repo')
25
+
26
+ try {
27
+ const gitCloneArgs = parsedSpecifier.ref
28
+ ? [
29
+ 'clone',
30
+ '--depth',
31
+ '1',
32
+ '--branch',
33
+ parsedSpecifier.ref,
34
+ parsedSpecifier.repoUrl,
35
+ clonedRepoPath,
36
+ ]
37
+ : ['clone', '--depth', '1', parsedSpecifier.repoUrl, clonedRepoPath]
38
+
39
+ await exec({
40
+ cmd: 'git',
41
+ args: gitCloneArgs,
42
+ pipe: false,
43
+ })
44
+
45
+ validateTemplateDirectory(clonedRepoPath, normalizedTemplateSource)
46
+
47
+ return {
48
+ templatePath: clonedRepoPath,
49
+ cleanup: async () => {
50
+ fs.removeSync(tempBase)
51
+ },
52
+ }
53
+ } catch (error) {
54
+ fs.removeSync(tempBase)
55
+ if (error instanceof Error && error.message) {
56
+ throw new Error(
57
+ `Failed to resolve template "${normalizedTemplateSource}": ${error.message}`
58
+ )
59
+ }
60
+ throw new Error(
61
+ `Failed to resolve template "${normalizedTemplateSource}".`
62
+ )
63
+ }
64
+ }
65
+
66
+ module.exports = resolveExternalTemplateSource
@@ -0,0 +1,26 @@
1
+ const path = require('node:path')
2
+ const fs = require('fs-extra')
3
+
4
+ const validateTemplateDirectory = (templatePath, templateSource) => {
5
+ if (!fs.existsSync(templatePath)) {
6
+ throw new Error(
7
+ `Template path "${templatePath}" from source "${templateSource}" does not exist.`
8
+ )
9
+ }
10
+
11
+ const stats = fs.statSync(templatePath)
12
+ if (!stats.isDirectory()) {
13
+ throw new Error(
14
+ `Template path "${templatePath}" from source "${templateSource}" is not a directory.`
15
+ )
16
+ }
17
+
18
+ const packageJsonPath = path.join(templatePath, 'package.json')
19
+ if (!fs.existsSync(packageJsonPath)) {
20
+ throw new Error(
21
+ `Template source "${templateSource}" is missing "package.json" at "${templatePath}".`
22
+ )
23
+ }
24
+ }
25
+
26
+ module.exports = validateTemplateDirectory
@@ -0,0 +1,89 @@
1
+ const test = require('tape')
2
+ const {
3
+ isGitTemplateSpecifier,
4
+ parseGitTemplateSpecifier,
5
+ } = require('../src/utils/isGitTemplateSpecifier')
6
+
7
+ test('isGitTemplateSpecifier detects supported GitHub patterns', (t) => {
8
+ t.plan(7)
9
+
10
+ t.equal(isGitTemplateSpecifier('basic'), false, 'built-in key is not git')
11
+ t.equal(
12
+ isGitTemplateSpecifier('react-router'),
13
+ false,
14
+ 'second built-in key is not git'
15
+ )
16
+ t.equal(
17
+ isGitTemplateSpecifier('owner/repo'),
18
+ true,
19
+ 'owner/repo shorthand is git'
20
+ )
21
+ t.equal(
22
+ isGitTemplateSpecifier('owner/repo#main'),
23
+ true,
24
+ 'owner/repo#ref shorthand is git'
25
+ )
26
+ t.equal(
27
+ isGitTemplateSpecifier('https://github.com/owner/repo'),
28
+ true,
29
+ 'GitHub URL is git'
30
+ )
31
+ t.equal(
32
+ isGitTemplateSpecifier('owner/repo#main:templates/app'),
33
+ false,
34
+ 'subdirectory syntax is no longer supported'
35
+ )
36
+ t.equal(isGitTemplateSpecifier(''), false, 'empty source is not git')
37
+ })
38
+
39
+ test('parseGitTemplateSpecifier parses shorthand with ref', (t) => {
40
+ t.plan(5)
41
+
42
+ const parsed = parseGitTemplateSpecifier('owner/repo#main')
43
+ t.equal(parsed.owner, 'owner', 'owner parsed')
44
+ t.equal(parsed.repo, 'repo', 'repo parsed')
45
+ t.equal(parsed.ref, 'main', 'ref parsed')
46
+ t.equal(
47
+ parsed.repoUrl,
48
+ 'https://github.com/owner/repo.git',
49
+ 'repo URL normalized'
50
+ )
51
+ t.equal(parsed.raw, 'owner/repo#main', 'raw source preserved')
52
+ })
53
+
54
+ test('parseGitTemplateSpecifier parses URL and strips .git suffix', (t) => {
55
+ t.plan(4)
56
+
57
+ const parsed = parseGitTemplateSpecifier(
58
+ 'https://github.com/acme/template.git#release'
59
+ )
60
+ t.equal(parsed.owner, 'acme', 'owner parsed from URL')
61
+ t.equal(parsed.repo, 'template', 'repo parsed and .git removed')
62
+ t.equal(parsed.ref, 'release', 'ref parsed from URL')
63
+ t.equal(parsed.raw, 'https://github.com/acme/template.git#release', 'raw')
64
+ })
65
+
66
+ test('parseGitTemplateSpecifier rejects unsupported or malformed inputs', (t) => {
67
+ t.plan(4)
68
+
69
+ t.throws(
70
+ () => parseGitTemplateSpecifier('owner-only'),
71
+ /Invalid template source/,
72
+ 'rejects malformed shorthand'
73
+ )
74
+ t.throws(
75
+ () => parseGitTemplateSpecifier('https://gitlab.com/acme/repo'),
76
+ /Only github.com repositories are supported|Unsupported template host/,
77
+ 'rejects non-GitHub host'
78
+ )
79
+ t.throws(
80
+ () => parseGitTemplateSpecifier('owner/repo#'),
81
+ /Ref cannot be empty/,
82
+ 'rejects empty ref'
83
+ )
84
+ t.throws(
85
+ () => parseGitTemplateSpecifier('owner/repo#main:templates/app'),
86
+ /Invalid template source/,
87
+ 'rejects subdirectory syntax'
88
+ )
89
+ })
@@ -0,0 +1,69 @@
1
+ const os = require('node:os')
2
+ const path = require('node:path')
3
+ const fs = require('fs-extra')
4
+ const test = require('tape')
5
+ const resolveExternalTemplateSource = require('../src/utils/resolveExternalTemplateSource')
6
+ const validateTemplateDirectory = require('../src/utils/validateTemplateDirectory')
7
+
8
+ const createTempTemplate = () => {
9
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd2-create-test-'))
10
+ fs.writeJsonSync(path.join(tempDir, 'package.json'), { name: 'fixture' })
11
+ return tempDir
12
+ }
13
+
14
+ test('validateTemplateDirectory accepts valid template directory', (t) => {
15
+ const tempDir = createTempTemplate()
16
+ t.plan(1)
17
+
18
+ try {
19
+ validateTemplateDirectory(tempDir, 'test-source')
20
+ t.pass('valid directory passes')
21
+ } finally {
22
+ fs.removeSync(tempDir)
23
+ }
24
+ })
25
+
26
+ test('resolveExternalTemplateSource fails for unknown non-git templates', async (t) => {
27
+ t.plan(1)
28
+
29
+ try {
30
+ await resolveExternalTemplateSource('unknown-template')
31
+ t.fail('should fail')
32
+ } catch (error) {
33
+ t.match(
34
+ String(error.message || error),
35
+ /Unknown template/,
36
+ 'returns unknown-template error'
37
+ )
38
+ }
39
+ })
40
+
41
+ test('resolveExternalTemplateSource fails fast for unsupported git hosts', async (t) => {
42
+ t.plan(1)
43
+
44
+ try {
45
+ await resolveExternalTemplateSource('https://gitlab.com/acme/repo')
46
+ t.fail('should fail')
47
+ } catch (error) {
48
+ t.match(
49
+ String(error.message || error),
50
+ /Unsupported template host|Only github.com repositories are supported/,
51
+ 'rejects unsupported host before clone'
52
+ )
53
+ }
54
+ })
55
+
56
+ test('resolveExternalTemplateSource rejects subdirectory syntax', async (t) => {
57
+ t.plan(1)
58
+
59
+ try {
60
+ await resolveExternalTemplateSource('owner/repo#main:templates/app')
61
+ t.fail('should fail')
62
+ } catch (error) {
63
+ t.match(
64
+ String(error.message || error),
65
+ /Unknown template|Invalid template source/,
66
+ 'subdirectory syntax is rejected'
67
+ )
68
+ }
69
+ })