From 470cd211e394ab9781f9548fd6b72623f027d85f Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Wed, 11 Feb 2026 17:42:04 +0200 Subject: [PATCH 1/3] feat(create-app): add custom template support via GitHub specifiers --- docs/commands/create-app.md | 15 ++- packages/create-app/src/index.js | 47 ++++++- .../src/utils/isGitTemplateSpecifier.js | 117 ++++++++++++++++ .../src/utils/resolveTemplateSource.js | 125 ++++++++++++++++++ 4 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 packages/create-app/src/utils/isGitTemplateSpecifier.js create mode 100644 packages/create-app/src/utils/resolveTemplateSource.js 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..c782eceb --- /dev/null +++ b/packages/create-app/src/utils/isGitTemplateSpecifier.js @@ -0,0 +1,117 @@ +const githubHosts = new Set(['github.com', 'www.github.com']) + +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.` + ) + } + + let ref = null + let subdir = null + if (refAndSubdir !== undefined) { + if (!refAndSubdir) { + throw new Error( + `Invalid template source "${rawTemplateSource}". Ref cannot be empty after "#".` + ) + } + const [parsedRef, ...subdirParts] = refAndSubdir.split(':') + ref = parsedRef || null + 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 ":".` + ) + } + } + + let owner = null + let repo = null + if (sourceWithoutRef.startsWith('https://')) { + let parsedUrl + try { + parsedUrl = new URL(sourceWithoutRef) + } catch (error) { + throw new Error( + `Invalid template URL "${sourceWithoutRef}". Use a valid GitHub repository URL.` + ) + } + + 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".` + ) + } + owner = pathParts[0] + repo = pathParts[1] + } else { + const match = sourceWithoutRef.match(/^([a-zA-Z0-9_.-]+)\/([^\s/]+)$/) + if (!match) { + throw new Error( + `Invalid template source "${rawTemplateSource}". Use "owner/repo", "owner/repo#ref", or "owner/repo#ref:subdir".` + ) + } + owner = match[1] + repo = match[2] + } + + 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..e189c2d6 --- /dev/null +++ b/packages/create-app/src/utils/resolveTemplateSource.js @@ -0,0 +1,125 @@ +const { execSync } = require('child_process') +const os = require('os') +const path = require('path') +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 cloneArgs = parsedSpecifier.ref + ? [ + 'git', + 'clone', + '--depth', + '1', + '--branch', + parsedSpecifier.ref, + parsedSpecifier.repoUrl, + clonedRepoPath, + ] + : [ + 'git', + 'clone', + '--depth', + '1', + parsedSpecifier.repoUrl, + clonedRepoPath, + ] + execSync(cloneArgs.join(' '), { stdio: 'ignore' }) + + 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 From 18b752591855bcbb3a9ba882378224611e6784ab Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Thu, 12 Feb 2026 09:58:27 +0200 Subject: [PATCH 2/3] refactor(create-app): reduce parser complexity and use node builtins --- .../src/utils/isGitTemplateSpecifier.js | 134 ++++++++++-------- .../src/utils/resolveTemplateSource.js | 6 +- 2 files changed, 74 insertions(+), 66 deletions(-) diff --git a/packages/create-app/src/utils/isGitTemplateSpecifier.js b/packages/create-app/src/utils/isGitTemplateSpecifier.js index c782eceb..8cbcefca 100644 --- a/packages/create-app/src/utils/isGitTemplateSpecifier.js +++ b/packages/create-app/src/utils/isGitTemplateSpecifier.js @@ -1,4 +1,68 @@ 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() @@ -14,69 +78,13 @@ const parseGitTemplateSpecifier = (templateSource) => { ) } - let ref = null - let subdir = null - if (refAndSubdir !== undefined) { - if (!refAndSubdir) { - throw new Error( - `Invalid template source "${rawTemplateSource}". Ref cannot be empty after "#".` - ) - } - const [parsedRef, ...subdirParts] = refAndSubdir.split(':') - ref = parsedRef || null - 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 ":".` - ) - } - } - - let owner = null - let repo = null - if (sourceWithoutRef.startsWith('https://')) { - let parsedUrl - try { - parsedUrl = new URL(sourceWithoutRef) - } catch (error) { - throw new Error( - `Invalid template URL "${sourceWithoutRef}". Use a valid GitHub repository URL.` - ) - } - - 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".` - ) - } - owner = pathParts[0] - repo = pathParts[1] - } else { - const match = sourceWithoutRef.match(/^([a-zA-Z0-9_.-]+)\/([^\s/]+)$/) - if (!match) { - throw new Error( - `Invalid template source "${rawTemplateSource}". Use "owner/repo", "owner/repo#ref", or "owner/repo#ref:subdir".` - ) - } - owner = match[1] - repo = match[2] - } + 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) diff --git a/packages/create-app/src/utils/resolveTemplateSource.js b/packages/create-app/src/utils/resolveTemplateSource.js index e189c2d6..f5e4bb08 100644 --- a/packages/create-app/src/utils/resolveTemplateSource.js +++ b/packages/create-app/src/utils/resolveTemplateSource.js @@ -1,6 +1,6 @@ -const { execSync } = require('child_process') -const os = require('os') -const path = require('path') +const { execSync } = require('node:child_process') +const os = require('node:os') +const path = require('node:path') const fs = require('fs-extra') const { isGitTemplateSpecifier, From 69f99e9781f6c320ee75d560d2ab652818e80a94 Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Thu, 12 Feb 2026 10:11:41 +0200 Subject: [PATCH 3/3] fix(create-app): run git clone without shell command strings --- .../create-app/src/utils/resolveTemplateSource.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/create-app/src/utils/resolveTemplateSource.js b/packages/create-app/src/utils/resolveTemplateSource.js index f5e4bb08..fcd73586 100644 --- a/packages/create-app/src/utils/resolveTemplateSource.js +++ b/packages/create-app/src/utils/resolveTemplateSource.js @@ -1,6 +1,6 @@ -const { execSync } = require('node:child_process') const os = require('node:os') const path = require('node:path') +const { exec } = require('@dhis2/cli-helpers-engine') const fs = require('fs-extra') const { isGitTemplateSpecifier, @@ -72,9 +72,8 @@ const resolveTemplateSource = async (templateSource, builtInTemplateMap) => { const clonedRepoPath = path.join(tempBase, 'repo') try { - const cloneArgs = parsedSpecifier.ref + const gitCloneArgs = parsedSpecifier.ref ? [ - 'git', 'clone', '--depth', '1', @@ -84,14 +83,17 @@ const resolveTemplateSource = async (templateSource, builtInTemplateMap) => { clonedRepoPath, ] : [ - 'git', 'clone', '--depth', '1', parsedSpecifier.repoUrl, clonedRepoPath, ] - execSync(cloneArgs.join(' '), { stdio: 'ignore' }) + await exec({ + cmd: 'git', + args: gitCloneArgs, + pipe: false, + }) const resolvedTemplatePath = resolveSubdirectory( clonedRepoPath,