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
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 = {
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) => {
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) => {
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