Skip to content
15 changes: 14 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,18 @@ 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 custom template from GitHub with branch + subdirectory
pnpm create @dhis2/app my-app --template owner/repo#main:templates/app-template

# 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
47 changes: 40 additions & 7 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 resolveTemplateSource = require('./utils/resolveTemplateSource')

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,7 +58,7 @@ const commandHandler = {
},
}

const getTemplateDirectory = (templateName) => {
const getBuiltInTemplateDirectory = (templateName) => {
return templateName === 'react-router'
? templates.templateWithReactRouter
: templates.templateWithList
Expand Down Expand Up @@ -86,7 +88,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 +108,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:templates/my-template)',
required: true,
})
} else {
selectedOptions.templateSource = template
}
}
}

Expand Down Expand Up @@ -158,8 +172,27 @@ const command = {
}

reporter.info('Copying template files')
const templateFiles = getTemplateDirectory(selectedOptions.templateName)
fs.copySync(templateFiles, cwd)
const builtInTemplateMap = {
Comment thread
derrick-nuby marked this conversation as resolved.
Outdated
basic: getBuiltInTemplateDirectory('basic'),
'react-router': getBuiltInTemplateDirectory('react-router'),
}
let resolvedTemplate
try {
resolvedTemplate = await resolveTemplateSource(
selectedOptions.templateSource,
builtInTemplateMap
)
fs.copySync(resolvedTemplate.templatePath, cwd)
} catch (error) {
reporter.error(
error instanceof Error ? error.message : String(error)
)
process.exit(1)
} finally {
if (resolvedTemplate) {
await resolvedTemplate.cleanup()
}
}

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

const parseRefAndSubdir = (rawTemplateSource, refAndSubdir) => {
if (refAndSubdir === undefined) {
return { ref: null, subdir: null }
}
if (!refAndSubdir) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Ref cannot be empty after "#".`
)
}

const [parsedRef, ...subdirParts] = refAndSubdir.split(':')
const ref = parsedRef || null
const subdir = subdirParts.length > 0 ? subdirParts.join(':') : null

if (!ref) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Ref cannot be empty after "#".`
)
}
if (subdir !== null && !subdir.trim()) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Subdirectory cannot be empty after ":".`
)
}

return { ref, subdir }
}

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 match = sourceWithoutRef.match(shorthandPattern)
if (!match) {
throw new Error(
`Invalid template source "${rawTemplateSource}". Use "owner/repo", "owner/repo#ref", or "owner/repo#ref:subdir".`
)
}

return {
owner: match[1],
repo: match[2],
}
}

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

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

const { ref, subdir } = parseRefAndSubdir(rawTemplateSource, refAndSubdir)
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,
subdir,
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
}

return /^[a-zA-Z0-9_.-]+\/[^\s/]+(?:#.+)?$/.test(rawTemplateSource)
}

module.exports = {
isGitTemplateSpecifier,
parseGitTemplateSpecifier,
}
127 changes: 127 additions & 0 deletions packages/create-app/src/utils/resolveTemplateSource.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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 ensureTemplateDirectory = (templatePath, templateSource) => {
Comment thread
derrick-nuby marked this conversation as resolved.
Outdated
if (!fs.existsSync(templatePath)) {
throw new Error(
`Template path "${templatePath}" from source "${templateSource}" does not exist.`
)
}
const stats = fs.statSync(templatePath)
if (!stats.isDirectory()) {
throw new Error(
`Template path "${templatePath}" from source "${templateSource}" is not a directory.`
)
}
const packageJsonPath = path.join(templatePath, 'package.json')
if (!fs.existsSync(packageJsonPath)) {
throw new Error(
`Template source "${templateSource}" is missing "package.json" at "${templatePath}".`
)
}
}

const resolveSubdirectory = (repoPath, subdir, templateSource) => {
Comment thread
derrick-nuby marked this conversation as resolved.
Outdated
if (!subdir) {
return repoPath
}

const cleanedSubdir = subdir.replace(/^\/+/, '')
const resolvedTemplatePath = path.resolve(repoPath, cleanedSubdir)
const repoPathWithSep = `${path.resolve(repoPath)}${path.sep}`
const validPath =
resolvedTemplatePath === path.resolve(repoPath) ||
resolvedTemplatePath.startsWith(repoPathWithSep)
if (!validPath) {
throw new Error(
`Invalid template subdirectory "${subdir}" in "${templateSource}". It resolves outside of the repository.`
)
}
return resolvedTemplatePath
}

const resolveTemplateSource = async (templateSource, builtInTemplateMap) => {
const normalizedTemplateSource = String(templateSource || '').trim()
const builtInPath = builtInTemplateMap[normalizedTemplateSource]
if (builtInPath) {
ensureTemplateDirectory(builtInPath, normalizedTemplateSource)
return {
templatePath: builtInPath,
cleanup: async () => {},
}
}

if (!isGitTemplateSpecifier(normalizedTemplateSource)) {
throw new Error(
`Unknown template "${normalizedTemplateSource}". Use one of [${Object.keys(
builtInTemplateMap
).join(', ')}] or a GitHub template specifier like "owner/repo#ref:subdir".`
)
}

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,
})

const resolvedTemplatePath = resolveSubdirectory(
clonedRepoPath,
parsedSpecifier.subdir,
normalizedTemplateSource
)
ensureTemplateDirectory(
resolvedTemplatePath,
normalizedTemplateSource
)

return {
templatePath: resolvedTemplatePath,
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 = resolveTemplateSource
Loading