-
Notifications
You must be signed in to change notification settings - Fork 7
feat(create-app): add custom template support via GitHub template sources #641
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| } |
| 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) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe better named
|
||
| 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) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is using a |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe for built-in templates, just resolve here in this module - no need to create a map, pass it and resolve it .. the
getTemplateDirectory(renamed togetBuiltInTemplateDirectory) already does most of that responsibility.So something like
no need to pass the map to resolveTemplateSource or make it aware of built-in tempaltes. (maybe rename the module to
resolveExternalTemplates)