Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/commands/create-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,15 @@ You can run `pnpm create @dhis2/app@alpha --help` for the list of options availa
template) [boolean] [default: false]
--typescript, --ts, --typeScript Use TypeScript or JS [boolean]
--template Which template to use (Basic, With
React Router, or GitHub
template specifier) [string]
React Router, community template
source, or GitHub template
specifier) [string]
--packageManager, --package, Package Manager
--packagemanager [string]
```

In interactive mode, template selection includes built-in templates, configured community templates, and a `Custom template from Git` option. For non-interactive usage, `--template` accepts built-in values (`basic`, `react-router`), configured community template sources, and direct GitHub specifiers.

## Examples

Here are some examples of how you can use the CLI
Expand All @@ -59,6 +62,9 @@ pnpm create @dhis2/app my-app --yes
# use the default settings but override the template
pnpm create @dhis2/app my-app --yes --template react-router

# use a configured community template source
pnpm create @dhis2/app my-app --template derrick-nuby/dhis2-ts-tailwind-react-router

# use a custom template from GitHub (owner/repo)
pnpm create @dhis2/app my-app --template owner/repo

Expand Down
3 changes: 2 additions & 1 deletion packages/create-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"@dhis2/cli-helpers-engine": "^3.2.2",
"@inquirer/prompts": "^7.8.4",
"fast-glob": "^3.3.3",
"fs-extra": "^11.3.3"
"fs-extra": "^11.3.3",
"yaml": "^2.8.1"
},
"private": false,
"keywords": [],
Expand Down
10 changes: 10 additions & 0 deletions packages/create-app/src/community-templates.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
templates:
- name: Tailwind + React Router
description: React Router DHIS2 app with Tailwind CSS
source: derrick-nuby/dhis2-ts-tailwind-react-router
maintainer:
name: Derrick Iradukunda
url: https://github.com/derrick-nuby
organisation:
name: HISP Rwanda
url: https://hisprwanda.org
92 changes: 86 additions & 6 deletions packages/create-app/src/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
const { execSync } = require('child_process')
const path = require('path')
const { execSync } = require('node:child_process')
const path = require('node:path')
const { reporter, exec, chalk } = require('@dhis2/cli-helpers-engine')
const { input, select } = require('@inquirer/prompts')
const fg = require('fast-glob')
const fs = require('fs-extra')
const getCommunityTemplates = require('./utils/getCommunityTemplates')
const { default: getPackageManager } = require('./utils/getPackageManager')
const { isGitTemplateSpecifier } = require('./utils/isGitTemplateSpecifier')
const resolveExternalTemplateSource = require('./utils/resolveExternalTemplateSource')
const {
resolveTemplateSourceInput,
} = require('./utils/resolveTemplateSourceInput')

process.on('uncaughtException', (error) => {
if (error instanceof Error && error.name === 'ExitPromptError') {
Expand All @@ -28,6 +33,7 @@ const templates = {
'../templates/template-ts-dataelements-react-router'
),
}
const builtInTemplateKeys = ['basic', 'react-router']

const commandHandler = {
command: '*', // default command
Expand All @@ -47,7 +53,7 @@ const commandHandler = {
},
template: {
description:
'Which template to use (Basic, With React Router, or GitHub template specifier)',
'Which template to use (basic, react-router, community template source, or GitHub template specifier)',
type: 'string',
},
packageManager: {
Expand All @@ -70,13 +76,35 @@ const getBuiltInTemplateDirectory = (templateName) => {
return null
}

const formatUnknownTemplateError = (templateSource, communityTemplates) => {
const communitySources = communityTemplates.map(
(template) => template.source
)
const availableTemplates = [...builtInTemplateKeys, ...communitySources]
const formattedTemplateList = availableTemplates.length
? availableTemplates.join(', ')
: '(none)'

return `Unknown template "${templateSource}". Available templates: ${formattedTemplateList}. Or use a GitHub template specifier like "owner/repo#ref" or "https://github.com/owner/repo#ref".`
}

const command = {
command: '[app]',
builder: (yargs) => {
yargs.command(commandHandler)
},
handler: async (argv) => {
let name = argv._[0] || argv.name
let communityTemplates = []

try {
communityTemplates = getCommunityTemplates()
} catch (error) {
reporter.error(
error instanceof Error ? error.message : String(error)
)
process.exit(1)
}

const useDefauls = argv.yes

Expand Down Expand Up @@ -121,6 +149,10 @@ const command = {
name: 'Template with React Router',
value: 'react-router',
},
{
name: 'Community templates',
value: 'community',
},
{
name: 'Custom template from Git',
value: 'custom-git',
Expand All @@ -134,6 +166,30 @@ const command = {
'Enter GitHub template specifier (e.g. owner/repo#main)',
required: true,
})
} else if (template === 'community') {
const communityTemplate = await select({
message: 'Select a community template',
choices: [
...communityTemplates.map((communityEntry) => ({
name: communityEntry.displayName,
value: communityEntry.source,
})),
{
name: 'Use a Git repository',
value: 'custom-git',
},
],
})

if (communityTemplate === 'custom-git') {
selectedOptions.templateSource = await input({
message:
'Enter GitHub template specifier (e.g. owner/repo#main)',
required: true,
})
} else {
selectedOptions.templateSource = communityTemplate
}
} else {
selectedOptions.templateSource = template
}
Expand Down Expand Up @@ -174,21 +230,45 @@ const command = {
})
reporter.debug('Successfully ran git clean')
} catch (err) {
reporter.debug(err)
reporter.debug(err instanceof Error ? err.message : String(err))
}

reporter.info('Copying template files')
let resolvedExternalTemplate
try {
const normalizedTemplateSource = String(
selectedOptions.templateSource || ''
).trim()
const builtInTemplatePath = getBuiltInTemplateDirectory(
selectedOptions.templateSource
normalizedTemplateSource
)

if (builtInTemplatePath) {
fs.copySync(builtInTemplatePath, cwd)
} else {
const resolvedTemplateSource = resolveTemplateSourceInput(
normalizedTemplateSource,
communityTemplates
)
const externalTemplateSource = resolvedTemplateSource.source

if (!isGitTemplateSpecifier(externalTemplateSource)) {
if (resolvedTemplateSource.kind === 'community') {
throw new Error(
`Community template "${resolvedTemplateSource.name}" has an invalid source "${externalTemplateSource}".`
)
}

throw new Error(
formatUnknownTemplateError(
normalizedTemplateSource,
communityTemplates
)
)
}

resolvedExternalTemplate = await resolveExternalTemplateSource(
selectedOptions.templateSource
externalTemplateSource
)
fs.copySync(resolvedExternalTemplate.templatePath, cwd)
}
Expand Down
159 changes: 159 additions & 0 deletions packages/create-app/src/utils/getCommunityTemplates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
const path = require('node:path')
const fs = require('fs-extra')
const yaml = require('yaml')

const defaultRegistryPath = path.join(__dirname, '../community-templates.yaml')

const getRequiredString = (value, fieldPath, registryPath) => {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(
`Invalid community template registry "${registryPath}": "${fieldPath}" must be a non-empty string.`
)
}

return value.trim()
}

const getOptionalString = (value, fieldPath, registryPath) => {
if (value === undefined || value === null) {
return null
}

if (typeof value !== 'string' || value.trim() === '') {
throw new Error(
`Invalid community template registry "${registryPath}": "${fieldPath}" must be a non-empty string when provided.`
)
}

return value.trim()
}

const getOptionalPersonMetadata = (value, fieldPath, registryPath) => {
if (value === undefined || value === null) {
return null
}

if (typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError(
`Invalid community template registry "${registryPath}": "${fieldPath}" must be an object when provided.`
)
}

const name = getOptionalString(
value.name,
`${fieldPath}.name`,
registryPath
)
const url = getOptionalString(value.url, `${fieldPath}.url`, registryPath)

if (!name && !url) {
return null
}

return {
...(name ? { name } : {}),
...(url ? { url } : {}),
}
}

const getCommunityTemplates = (registryPath = defaultRegistryPath) => {
let registryFileContent

try {
registryFileContent = fs.readFileSync(registryPath, 'utf8')
} catch (error) {
const detail =
error instanceof Error && error.message ? ` ${error.message}` : ''
throw new Error(
`Failed to read community template registry "${registryPath}".${detail}`
)
}

let parsedRegistry
try {
parsedRegistry = yaml.parse(registryFileContent)
} catch (error) {
const detail =
error instanceof Error && error.message ? ` ${error.message}` : ''
throw new Error(
`Failed to parse community template registry "${registryPath}".${detail}`
)
}

if (!parsedRegistry || !Array.isArray(parsedRegistry.templates)) {
throw new Error(
`Invalid community template registry "${registryPath}": "templates" must be an array.`
)
}

const knownTemplateSources = new Set()

return parsedRegistry.templates.map((template, index) => {
const templateFieldPrefix = `templates[${index}]`
if (
!template ||
typeof template !== 'object' ||
Array.isArray(template)
) {
throw new Error(
`Invalid community template registry "${registryPath}": "${templateFieldPrefix}" must be an object.`
)
}

const name = getRequiredString(
template.name,
`${templateFieldPrefix}.name`,
registryPath
)
const source = getRequiredString(
template.source,
`${templateFieldPrefix}.source`,
registryPath
)

if (knownTemplateSources.has(source)) {
throw new Error(
`Invalid community template registry "${registryPath}": duplicate template source "${source}" in "${templateFieldPrefix}.source".`
)
}
knownTemplateSources.add(source)

const description = getOptionalString(
template.description,
`${templateFieldPrefix}.description`,
registryPath
)
const maintainer = getOptionalPersonMetadata(
template.maintainer,
`${templateFieldPrefix}.maintainer`,
registryPath
)
const organisation = getOptionalPersonMetadata(
template.organisation,
`${templateFieldPrefix}.organisation`,
registryPath
)

const attributionParts = []
if (maintainer?.name) {
attributionParts.push(`by ${maintainer.name}`)
}
if (organisation?.name) {
attributionParts.push(`org: ${organisation.name}`)
}
const displayName = attributionParts.length
? `${name} (${attributionParts.join(', ')})`
: name

return {
name,
source,
...(description ? { description } : {}),
...(maintainer ? { maintainer } : {}),
...(organisation ? { organisation } : {}),
displayName,
}
})
}

module.exports = getCommunityTemplates
27 changes: 27 additions & 0 deletions packages/create-app/src/utils/resolveTemplateSourceInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const resolveTemplateSourceInput = (
templateSource,
communityTemplates = []
) => {
const normalizedTemplateSource = String(templateSource || '').trim()

const matchedCommunityTemplate = communityTemplates.find(
(communityTemplate) =>
communityTemplate.source === normalizedTemplateSource
)
if (matchedCommunityTemplate) {
return {
kind: 'community',
name: matchedCommunityTemplate.name,
source: matchedCommunityTemplate.source,
}
}

return {
kind: 'external',
source: normalizedTemplateSource,
}
}

module.exports = {
resolveTemplateSourceInput,
}
Loading
Loading