Skip to content
3 changes: 2 additions & 1 deletion .github/workflows/dhis2-verify-commits.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: 'dhis2: verify (commits)'

on:
push:
pull_request:
types: ['opened', 'edited', 'reopened', 'synchronize']
types: ['opened', 'edited', 'reopened', 'synchronize', 'labeled']

jobs:
lint-pr-title:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/dhis2-verify-node.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: 'dhis2: verify (node)'

on: push
on:
push:
pull_request:
types: ['opened', 'edited', 'reopened', 'synchronize', 'labeled']

concurrency:
group: ${{ github.workflow}}-${{ github.ref }}
Expand Down
12 changes: 11 additions & 1 deletion docs/commands/create-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ 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) [string]
React Router, or GitHub
template specifier) [string]
--packageManager, --package, Package Manager
--packagemanager [string]
```
Expand All @@ -58,6 +59,15 @@ 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 custom template from GitHub (owner/repo)
pnpm create @dhis2/app my-app --template owner/repo

# use a custom template from GitHub with a branch/tag/commit
pnpm create @dhis2/app my-app --template owner/repo#main

# use a full GitHub URL
pnpm create @dhis2/app my-app --template https://github.com/owner/repo

# use yarn as a package manager (and prompt for other settings)
pnpm create @dhis2/app my-app --packageManager yarn

Expand Down
62 changes: 52 additions & 10 deletions packages/create-app/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { input, select } = require('@inquirer/prompts')
const fg = require('fast-glob')
const fs = require('fs-extra')
const { default: getPackageManager } = require('./utils/getPackageManager')
const resolveExternalTemplateSource = require('./utils/resolveExternalTemplateSource')

process.on('uncaughtException', (error) => {
if (error instanceof Error && error.name === 'ExitPromptError') {
Expand Down Expand Up @@ -45,7 +46,8 @@ const commandHandler = {
alias: ['ts', 'typeScript'],
},
template: {
description: 'Which template to use (Basic, With React Router)',
description:
'Which template to use (Basic, With React Router, or GitHub template specifier)',
type: 'string',
},
packageManager: {
Expand All @@ -56,10 +58,16 @@ const commandHandler = {
},
}

const getTemplateDirectory = (templateName) => {
return templateName === 'react-router'
? templates.templateWithReactRouter
: templates.templateWithList
const getBuiltInTemplateDirectory = (templateName) => {
if (templateName === 'basic') {
return templates.templateWithList
}

if (templateName === 'react-router') {
return templates.templateWithReactRouter
}

return null
}

const command = {
Expand All @@ -86,7 +94,7 @@ const command = {
typeScript: argv.typescript ?? true,
packageManager:
argv.packageManager ?? getPackageManager() ?? 'pnpm',
templateName: argv.template ?? 'basic',
templateSource: argv.template ?? 'basic',
}

if (!useDefauls) {
Expand All @@ -106,17 +114,29 @@ const command = {
if (argv.template === undefined) {
const template = await select({
message: 'Select a template',
default: 'ts',
default: 'basic',
choices: [
{ name: 'Basic Template', value: 'basic' },
{
name: 'Template with React Router',
value: 'react-router',
},
{
name: 'Custom template from Git',
value: 'custom-git',
},
],
})

selectedOptions.templateName = template
if (template === 'custom-git') {
selectedOptions.templateSource = await input({
message:
'Enter GitHub template specifier (e.g. owner/repo#main)',
required: true,
})
} else {
selectedOptions.templateSource = template
}
}
}

Expand Down Expand Up @@ -158,8 +178,30 @@ const command = {
}

reporter.info('Copying template files')
const templateFiles = getTemplateDirectory(selectedOptions.templateName)
fs.copySync(templateFiles, cwd)
let resolvedExternalTemplate
try {
const builtInTemplatePath = getBuiltInTemplateDirectory(
selectedOptions.templateSource
)

if (builtInTemplatePath) {
fs.copySync(builtInTemplatePath, cwd)
} else {
resolvedExternalTemplate = await resolveExternalTemplateSource(
selectedOptions.templateSource
)
fs.copySync(resolvedExternalTemplate.templatePath, cwd)
}
} catch (error) {
reporter.error(
error instanceof Error ? error.message : String(error)
)
process.exit(1)
} finally {
if (resolvedExternalTemplate) {
await resolvedExternalTemplate.cleanup()
}
}

const paths = {
base: cwd,
Expand Down
162 changes: 162 additions & 0 deletions packages/create-app/src/utils/isGitTemplateSpecifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
const githubHosts = new Set(['github.com', 'www.github.com'])
const ownerPattern = /^[a-zA-Z0-9_.-]+$/

const parseRef = (rawTemplateSource, refPart) => {
if (refPart === undefined) {
return null
}
if (!refPart) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Ref cannot be empty after "#".`
)
}
if (refPart.includes(':')) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".`
)
}

return refPart
}

const parseGithubUrlSource = (sourceWithoutRef) => {
const parsedUrl = new URL(sourceWithoutRef)
if (!githubHosts.has(parsedUrl.host)) {
throw new Error(
`Unsupported template host "${parsedUrl.host}". Only github.com repositories are supported.`
)
}

const pathParts = parsedUrl.pathname.split('/').filter(Boolean).slice(0, 2)
if (pathParts.length < 2) {
throw new Error(
`Invalid GitHub repository path in "${sourceWithoutRef}". Use "owner/repo".`
)
}

return {
owner: pathParts[0],
repo: pathParts[1],
}
}

const parseGithubShorthandSource = (rawTemplateSource, sourceWithoutRef) => {
const separatorIndex = sourceWithoutRef.indexOf('/')
const hasSingleSeparator =
separatorIndex > 0 &&
separatorIndex === sourceWithoutRef.lastIndexOf('/')
if (!hasSingleSeparator) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".`
)
}

const owner = sourceWithoutRef.slice(0, separatorIndex)
const repo = sourceWithoutRef.slice(separatorIndex + 1)
if (
!ownerPattern.test(owner) ||
!repo ||
/\s/.test(repo) ||
repo.includes('/')
) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".`
)
}

return {
owner,
repo,
}
}

const isValidRefPart = (refPart) => {
if (refPart === undefined) {
return true
}

return Boolean(refPart) && !refPart.includes(':')
}

const isValidShorthandSource = (sourceWithoutRef) => {
const separatorIndex = sourceWithoutRef.indexOf('/')
const hasSingleSeparator =
separatorIndex > 0 &&
separatorIndex === sourceWithoutRef.lastIndexOf('/')
if (!hasSingleSeparator) {
return false
}

const owner = sourceWithoutRef.slice(0, separatorIndex)
const repo = sourceWithoutRef.slice(separatorIndex + 1)

return (
ownerPattern.test(owner) &&
Boolean(repo) &&
!/\s/.test(repo) &&
!repo.includes('/')
)
}

const parseGitTemplateSpecifier = (templateSource) => {
const rawTemplateSource = String(templateSource || '').trim()
if (!rawTemplateSource) {
throw new Error('Template source cannot be empty.')
}

const [sourceWithoutRef, refPart, ...rest] = rawTemplateSource.split('#')
if (rest.length > 0) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Use at most one "#" to specify a ref.`
)
}

const ref = parseRef(rawTemplateSource, refPart)
const sourceInfo = sourceWithoutRef.startsWith('https://')
? parseGithubUrlSource(sourceWithoutRef)
: parseGithubShorthandSource(rawTemplateSource, sourceWithoutRef)

const owner = sourceInfo.owner
let repo = sourceInfo.repo

if (repo.endsWith('.git')) {
repo = repo.slice(0, -4)
}

if (!owner || !repo) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Missing GitHub owner or repository name.`
)
}

return {
owner,
repo,
ref,
repoUrl: `https://github.com/${owner}/${repo}.git`,
raw: rawTemplateSource,
}
}

const isGitTemplateSpecifier = (templateSource) => {
const rawTemplateSource = String(templateSource || '').trim()
if (!rawTemplateSource) {
return false
}

if (rawTemplateSource.startsWith('https://')) {
return true
}

const [sourceWithoutRef, refPart, ...rest] = rawTemplateSource.split('#')
if (rest.length > 0 || !isValidRefPart(refPart)) {
return false
}

return isValidShorthandSource(sourceWithoutRef)
}

module.exports = {
isGitTemplateSpecifier,
parseGitTemplateSpecifier,
}
66 changes: 66 additions & 0 deletions packages/create-app/src/utils/resolveExternalTemplateSource.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const os = require('node:os')
const path = require('node:path')
const { exec } = require('@dhis2/cli-helpers-engine')
const fs = require('fs-extra')
const {
isGitTemplateSpecifier,
parseGitTemplateSpecifier,
} = require('./isGitTemplateSpecifier')
const validateTemplateDirectory = require('./validateTemplateDirectory')

const resolveExternalTemplateSource = async (templateSource) => {
const normalizedTemplateSource = String(templateSource || '').trim()

if (!isGitTemplateSpecifier(normalizedTemplateSource)) {
throw new Error(
`Unknown template "${normalizedTemplateSource}". Use one of [basic, react-router] or a GitHub template specifier like "owner/repo#ref".`
)
}

const parsedSpecifier = parseGitTemplateSpecifier(normalizedTemplateSource)
const tempBase = fs.mkdtempSync(
path.join(os.tmpdir(), 'd2-create-template-source-')
)
const clonedRepoPath = path.join(tempBase, 'repo')

try {
const gitCloneArgs = parsedSpecifier.ref
? [
'clone',
'--depth',
'1',
'--branch',
parsedSpecifier.ref,
parsedSpecifier.repoUrl,
clonedRepoPath,
]
: ['clone', '--depth', '1', parsedSpecifier.repoUrl, clonedRepoPath]

await exec({
cmd: 'git',
args: gitCloneArgs,
pipe: false,
})

validateTemplateDirectory(clonedRepoPath, normalizedTemplateSource)

return {
templatePath: clonedRepoPath,
cleanup: async () => {
fs.removeSync(tempBase)
},
}
} catch (error) {
fs.removeSync(tempBase)
if (error instanceof Error && error.message) {
throw new Error(
`Failed to resolve template "${normalizedTemplateSource}": ${error.message}`
)
}
throw new Error(
`Failed to resolve template "${normalizedTemplateSource}".`
)
}
}

module.exports = resolveExternalTemplateSource
Loading
Loading