From 470cd211e394ab9781f9548fd6b72623f027d85f Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Wed, 11 Feb 2026 17:42:04 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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, From ec1e867385396a61a3a4b6e8cfcd75724df43830 Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Mon, 23 Feb 2026 16:18:06 +0200 Subject: [PATCH 4/7] refactor(create-app): resolve git templates via external resolver and drop subdir syntax --- docs/commands/create-app.md | 3 - packages/create-app/src/index.js | 41 +++--- .../src/utils/isGitTemplateSpecifier.js | 65 +++++---- .../utils/resolveExternalTemplateSource.js | 66 +++++++++ .../src/utils/resolveTemplateSource.js | 127 ------------------ .../src/utils/validateTemplateDirectory.js | 26 ++++ .../tests/is-git-template-specifier.js | 89 ++++++++++++ .../tests/resolve-external-template-source.js | 69 ++++++++++ 8 files changed, 312 insertions(+), 174 deletions(-) create mode 100644 packages/create-app/src/utils/resolveExternalTemplateSource.js delete mode 100644 packages/create-app/src/utils/resolveTemplateSource.js create mode 100644 packages/create-app/src/utils/validateTemplateDirectory.js create mode 100644 packages/create-app/tests/is-git-template-specifier.js create mode 100644 packages/create-app/tests/resolve-external-template-source.js diff --git a/docs/commands/create-app.md b/docs/commands/create-app.md index 0862a925..fd2a6dce 100644 --- a/docs/commands/create-app.md +++ b/docs/commands/create-app.md @@ -65,9 +65,6 @@ 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 diff --git a/packages/create-app/src/index.js b/packages/create-app/src/index.js index fe121834..0db9539c 100644 --- a/packages/create-app/src/index.js +++ b/packages/create-app/src/index.js @@ -5,7 +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') +const resolveExternalTemplateSource = require('./utils/resolveExternalTemplateSource') process.on('uncaughtException', (error) => { if (error instanceof Error && error.name === 'ExitPromptError') { @@ -59,9 +59,15 @@ const commandHandler = { } const getBuiltInTemplateDirectory = (templateName) => { - return templateName === 'react-router' - ? templates.templateWithReactRouter - : templates.templateWithList + if (templateName === 'basic') { + return templates.templateWithList + } + + if (templateName === 'react-router') { + return templates.templateWithReactRouter + } + + return null } const command = { @@ -125,7 +131,7 @@ const command = { if (template === 'custom-git') { selectedOptions.templateSource = await input({ message: - 'Enter GitHub template specifier (e.g. owner/repo#main:templates/my-template)', + 'Enter GitHub template specifier (e.g. owner/repo#main)', required: true, }) } else { @@ -172,25 +178,28 @@ const command = { } reporter.info('Copying template files') - const builtInTemplateMap = { - basic: getBuiltInTemplateDirectory('basic'), - 'react-router': getBuiltInTemplateDirectory('react-router'), - } - let resolvedTemplate + let resolvedExternalTemplate try { - resolvedTemplate = await resolveTemplateSource( - selectedOptions.templateSource, - builtInTemplateMap + const builtInTemplatePath = getBuiltInTemplateDirectory( + selectedOptions.templateSource ) - fs.copySync(resolvedTemplate.templatePath, cwd) + + if (builtInTemplatePath) { + fs.copySync(builtInTemplatePath, cwd) + } else { + resolvedExternalTemplate = await resolveExternalTemplateSource( + selectedOptions.templateSource + ) + fs.copySync(resolvedExternalTemplate.templatePath, cwd) + } } catch (error) { reporter.error( error instanceof Error ? error.message : String(error) ) process.exit(1) } finally { - if (resolvedTemplate) { - await resolvedTemplate.cleanup() + if (resolvedExternalTemplate) { + await resolvedExternalTemplate.cleanup() } } diff --git a/packages/create-app/src/utils/isGitTemplateSpecifier.js b/packages/create-app/src/utils/isGitTemplateSpecifier.js index 8cbcefca..7982ed3f 100644 --- a/packages/create-app/src/utils/isGitTemplateSpecifier.js +++ b/packages/create-app/src/utils/isGitTemplateSpecifier.js @@ -1,32 +1,22 @@ const githubHosts = new Set(['github.com', 'www.github.com']) -const shorthandPattern = /^([a-zA-Z0-9_.-]+)\/([^\s/]+)$/ +const ownerPattern = /^[a-zA-Z0-9_.-]+$/ -const parseRefAndSubdir = (rawTemplateSource, refAndSubdir) => { - if (refAndSubdir === undefined) { - return { ref: null, subdir: null } +const parseRef = (rawTemplateSource, refPart) => { + if (refPart === undefined) { + return null } - if (!refAndSubdir) { + if (!refPart) { 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()) { + if (refPart.includes(':')) { throw new Error( - `Invalid template source "${rawTemplateSource}". Subdirectory cannot be empty after ":".` + `Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".` ) } - return { ref, subdir } + return refPart } const parseGithubUrlSource = (sourceWithoutRef) => { @@ -51,16 +41,32 @@ const parseGithubUrlSource = (sourceWithoutRef) => { } const parseGithubShorthandSource = (rawTemplateSource, sourceWithoutRef) => { - const match = sourceWithoutRef.match(shorthandPattern) - if (!match) { + const separatorIndex = sourceWithoutRef.indexOf('/') + const hasSingleSeparator = + separatorIndex > 0 && + separatorIndex === sourceWithoutRef.lastIndexOf('/') + if (!hasSingleSeparator) { throw new Error( - `Invalid template source "${rawTemplateSource}". Use "owner/repo", "owner/repo#ref", or "owner/repo#ref:subdir".` + `Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".` + ) + } + + const owner = sourceWithoutRef.slice(0, separatorIndex) + const repo = sourceWithoutRef.slice(separatorIndex + 1) + if ( + !ownerPattern.test(owner) || + !repo || + /\s/.test(repo) || + repo.includes('/') + ) { + throw new Error( + `Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".` ) } return { - owner: match[1], - repo: match[2], + owner, + repo, } } @@ -70,15 +76,14 @@ const parseGitTemplateSpecifier = (templateSource) => { throw new Error('Template source cannot be empty.') } - const [sourceWithoutRef, refAndSubdir, ...rest] = - rawTemplateSource.split('#') + const [sourceWithoutRef, refPart, ...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 ref = parseRef(rawTemplateSource, refPart) const sourceInfo = sourceWithoutRef.startsWith('https://') ? parseGithubUrlSource(sourceWithoutRef) : parseGithubShorthandSource(rawTemplateSource, sourceWithoutRef) @@ -100,7 +105,6 @@ const parseGitTemplateSpecifier = (templateSource) => { owner, repo, ref, - subdir, repoUrl: `https://github.com/${owner}/${repo}.git`, raw: rawTemplateSource, } @@ -116,7 +120,12 @@ const isGitTemplateSpecifier = (templateSource) => { return true } - return /^[a-zA-Z0-9_.-]+\/[^\s/]+(?:#.+)?$/.test(rawTemplateSource) + try { + parseGitTemplateSpecifier(rawTemplateSource) + return true + } catch (_error) { + return false + } } module.exports = { diff --git a/packages/create-app/src/utils/resolveExternalTemplateSource.js b/packages/create-app/src/utils/resolveExternalTemplateSource.js new file mode 100644 index 00000000..c63e9719 --- /dev/null +++ b/packages/create-app/src/utils/resolveExternalTemplateSource.js @@ -0,0 +1,66 @@ +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 validateTemplateDirectory = require('./validateTemplateDirectory') + +const resolveExternalTemplateSource = async (templateSource) => { + const normalizedTemplateSource = String(templateSource || '').trim() + + if (!isGitTemplateSpecifier(normalizedTemplateSource)) { + throw new Error( + `Unknown template "${normalizedTemplateSource}". Use one of [basic, react-router] or a GitHub template specifier like "owner/repo#ref".` + ) + } + + 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, + }) + + validateTemplateDirectory(clonedRepoPath, normalizedTemplateSource) + + return { + templatePath: clonedRepoPath, + 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 = resolveExternalTemplateSource diff --git a/packages/create-app/src/utils/resolveTemplateSource.js b/packages/create-app/src/utils/resolveTemplateSource.js deleted file mode 100644 index fcd73586..00000000 --- a/packages/create-app/src/utils/resolveTemplateSource.js +++ /dev/null @@ -1,127 +0,0 @@ -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 diff --git a/packages/create-app/src/utils/validateTemplateDirectory.js b/packages/create-app/src/utils/validateTemplateDirectory.js new file mode 100644 index 00000000..82d09bb7 --- /dev/null +++ b/packages/create-app/src/utils/validateTemplateDirectory.js @@ -0,0 +1,26 @@ +const path = require('node:path') +const fs = require('fs-extra') + +const validateTemplateDirectory = (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}".` + ) + } +} + +module.exports = validateTemplateDirectory diff --git a/packages/create-app/tests/is-git-template-specifier.js b/packages/create-app/tests/is-git-template-specifier.js new file mode 100644 index 00000000..f552860f --- /dev/null +++ b/packages/create-app/tests/is-git-template-specifier.js @@ -0,0 +1,89 @@ +const test = require('tape') +const { + isGitTemplateSpecifier, + parseGitTemplateSpecifier, +} = require('../src/utils/isGitTemplateSpecifier') + +test('isGitTemplateSpecifier detects supported GitHub patterns', (t) => { + t.plan(7) + + t.equal(isGitTemplateSpecifier('basic'), false, 'built-in key is not git') + t.equal( + isGitTemplateSpecifier('react-router'), + false, + 'second built-in key is not git' + ) + t.equal( + isGitTemplateSpecifier('owner/repo'), + true, + 'owner/repo shorthand is git' + ) + t.equal( + isGitTemplateSpecifier('owner/repo#main'), + true, + 'owner/repo#ref shorthand is git' + ) + t.equal( + isGitTemplateSpecifier('https://github.com/owner/repo'), + true, + 'GitHub URL is git' + ) + t.equal( + isGitTemplateSpecifier('owner/repo#main:templates/app'), + false, + 'subdirectory syntax is no longer supported' + ) + t.equal(isGitTemplateSpecifier(''), false, 'empty source is not git') +}) + +test('parseGitTemplateSpecifier parses shorthand with ref', (t) => { + t.plan(5) + + const parsed = parseGitTemplateSpecifier('owner/repo#main') + t.equal(parsed.owner, 'owner', 'owner parsed') + t.equal(parsed.repo, 'repo', 'repo parsed') + t.equal(parsed.ref, 'main', 'ref parsed') + t.equal( + parsed.repoUrl, + 'https://github.com/owner/repo.git', + 'repo URL normalized' + ) + t.equal(parsed.raw, 'owner/repo#main', 'raw source preserved') +}) + +test('parseGitTemplateSpecifier parses URL and strips .git suffix', (t) => { + t.plan(4) + + const parsed = parseGitTemplateSpecifier( + 'https://github.com/acme/template.git#release' + ) + t.equal(parsed.owner, 'acme', 'owner parsed from URL') + t.equal(parsed.repo, 'template', 'repo parsed and .git removed') + t.equal(parsed.ref, 'release', 'ref parsed from URL') + t.equal(parsed.raw, 'https://github.com/acme/template.git#release', 'raw') +}) + +test('parseGitTemplateSpecifier rejects unsupported or malformed inputs', (t) => { + t.plan(4) + + t.throws( + () => parseGitTemplateSpecifier('owner-only'), + /Invalid template source/, + 'rejects malformed shorthand' + ) + t.throws( + () => parseGitTemplateSpecifier('https://gitlab.com/acme/repo'), + /Only github.com repositories are supported|Unsupported template host/, + 'rejects non-GitHub host' + ) + t.throws( + () => parseGitTemplateSpecifier('owner/repo#'), + /Ref cannot be empty/, + 'rejects empty ref' + ) + t.throws( + () => parseGitTemplateSpecifier('owner/repo#main:templates/app'), + /Invalid template source/, + 'rejects subdirectory syntax' + ) +}) diff --git a/packages/create-app/tests/resolve-external-template-source.js b/packages/create-app/tests/resolve-external-template-source.js new file mode 100644 index 00000000..dcb81d14 --- /dev/null +++ b/packages/create-app/tests/resolve-external-template-source.js @@ -0,0 +1,69 @@ +const os = require('node:os') +const path = require('node:path') +const fs = require('fs-extra') +const test = require('tape') +const resolveExternalTemplateSource = require('../src/utils/resolveExternalTemplateSource') +const validateTemplateDirectory = require('../src/utils/validateTemplateDirectory') + +const createTempTemplate = () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd2-create-test-')) + fs.writeJsonSync(path.join(tempDir, 'package.json'), { name: 'fixture' }) + return tempDir +} + +test('validateTemplateDirectory accepts valid template directory', (t) => { + const tempDir = createTempTemplate() + t.plan(1) + + try { + validateTemplateDirectory(tempDir, 'test-source') + t.pass('valid directory passes') + } finally { + fs.removeSync(tempDir) + } +}) + +test('resolveExternalTemplateSource fails for unknown non-git templates', async (t) => { + t.plan(1) + + try { + await resolveExternalTemplateSource('unknown-template') + t.fail('should fail') + } catch (error) { + t.match( + String(error.message || error), + /Unknown template/, + 'returns unknown-template error' + ) + } +}) + +test('resolveExternalTemplateSource fails fast for unsupported git hosts', async (t) => { + t.plan(1) + + try { + await resolveExternalTemplateSource('https://gitlab.com/acme/repo') + t.fail('should fail') + } catch (error) { + t.match( + String(error.message || error), + /Unsupported template host|Only github.com repositories are supported/, + 'rejects unsupported host before clone' + ) + } +}) + +test('resolveExternalTemplateSource rejects subdirectory syntax', async (t) => { + t.plan(1) + + try { + await resolveExternalTemplateSource('owner/repo#main:templates/app') + t.fail('should fail') + } catch (error) { + t.match( + String(error.message || error), + /Unknown template|Invalid template source/, + 'subdirectory syntax is rejected' + ) + } +}) From 7cd0714548fec1866c721c2f99c4d3f16b207353 Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Mon, 23 Feb 2026 16:22:06 +0200 Subject: [PATCH 5/7] refactor(create-app): align git template resolver and remove ignored exception path --- .../src/utils/isGitTemplateSpecifier.js | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/create-app/src/utils/isGitTemplateSpecifier.js b/packages/create-app/src/utils/isGitTemplateSpecifier.js index 7982ed3f..c58ad982 100644 --- a/packages/create-app/src/utils/isGitTemplateSpecifier.js +++ b/packages/create-app/src/utils/isGitTemplateSpecifier.js @@ -70,6 +70,34 @@ const parseGithubShorthandSource = (rawTemplateSource, sourceWithoutRef) => { } } +const isValidRefPart = (refPart) => { + if (refPart === undefined) { + return true + } + + return Boolean(refPart) && !refPart.includes(':') +} + +const isValidShorthandSource = (sourceWithoutRef) => { + const separatorIndex = sourceWithoutRef.indexOf('/') + const hasSingleSeparator = + separatorIndex > 0 && + separatorIndex === sourceWithoutRef.lastIndexOf('/') + if (!hasSingleSeparator) { + return false + } + + const owner = sourceWithoutRef.slice(0, separatorIndex) + const repo = sourceWithoutRef.slice(separatorIndex + 1) + + return ( + ownerPattern.test(owner) && + Boolean(repo) && + !/\s/.test(repo) && + !repo.includes('/') + ) +} + const parseGitTemplateSpecifier = (templateSource) => { const rawTemplateSource = String(templateSource || '').trim() if (!rawTemplateSource) { @@ -120,12 +148,12 @@ const isGitTemplateSpecifier = (templateSource) => { return true } - try { - parseGitTemplateSpecifier(rawTemplateSource) - return true - } catch (_error) { + const [sourceWithoutRef, refPart, ...rest] = rawTemplateSource.split('#') + if (rest.length > 0 || !isValidRefPart(refPart)) { return false } + + return isValidShorthandSource(sourceWithoutRef) } module.exports = { From 471856fe0d92661be85c6b6056557d5419c7a4b2 Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Tue, 24 Feb 2026 09:37:40 +0200 Subject: [PATCH 6/7] fix(verify-commits): include 'labeled' type in pull request event triggers --- .github/workflows/dhis2-verify-commits.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dhis2-verify-commits.yml b/.github/workflows/dhis2-verify-commits.yml index 54862e67..71b7664a 100644 --- a/.github/workflows/dhis2-verify-commits.yml +++ b/.github/workflows/dhis2-verify-commits.yml @@ -1,8 +1,9 @@ name: 'dhis2: verify (commits)' on: + push: pull_request: - types: ['opened', 'edited', 'reopened', 'synchronize'] + types: ['opened', 'edited', 'reopened', 'synchronize', 'labeled'] jobs: lint-pr-title: From 54fc0ec56639e832fa43236426be870f45f70919 Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Tue, 24 Feb 2026 10:00:52 +0200 Subject: [PATCH 7/7] fix(verify-commits): update pull request event types for verification workflow --- .github/workflows/dhis2-verify-node.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dhis2-verify-node.yml b/.github/workflows/dhis2-verify-node.yml index b12c3798..a8625093 100644 --- a/.github/workflows/dhis2-verify-node.yml +++ b/.github/workflows/dhis2-verify-node.yml @@ -1,6 +1,9 @@ name: 'dhis2: verify (node)' -on: push +on: + push: + pull_request: + types: ['opened', 'edited', 'reopened', 'synchronize', 'labeled'] concurrency: group: ${{ github.workflow}}-${{ github.ref }}