diff --git a/docs/commands/create-app.md b/docs/commands/create-app.md index 542c1c90..0862a925 100644 --- a/docs/commands/create-app.md +++ b/docs/commands/create-app.md @@ -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] ``` @@ -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 diff --git a/packages/create-app/src/index.js b/packages/create-app/src/index.js index bcaa6245..fe121834 100644 --- a/packages/create-app/src/index.js +++ b/packages/create-app/src/index.js @@ -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') { @@ -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: { @@ -56,7 +58,7 @@ const commandHandler = { }, } -const getTemplateDirectory = (templateName) => { +const getBuiltInTemplateDirectory = (templateName) => { return templateName === 'react-router' ? templates.templateWithReactRouter : templates.templateWithList @@ -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) { @@ -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 + } } } @@ -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, diff --git a/packages/create-app/src/utils/isGitTemplateSpecifier.js b/packages/create-app/src/utils/isGitTemplateSpecifier.js new file mode 100644 index 00000000..8cbcefca --- /dev/null +++ b/packages/create-app/src/utils/isGitTemplateSpecifier.js @@ -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, +} diff --git a/packages/create-app/src/utils/resolveTemplateSource.js b/packages/create-app/src/utils/resolveTemplateSource.js new file mode 100644 index 00000000..fcd73586 --- /dev/null +++ b/packages/create-app/src/utils/resolveTemplateSource.js @@ -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