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: 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 }} diff --git a/docs/commands/create-app.md b/docs/commands/create-app.md index 542c1c90..fd2a6dce 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,15 @@ 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 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..0db9539c 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 resolveExternalTemplateSource = require('./utils/resolveExternalTemplateSource') 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,10 +58,16 @@ const commandHandler = { }, } -const getTemplateDirectory = (templateName) => { - return templateName === 'react-router' - ? templates.templateWithReactRouter - : templates.templateWithList +const getBuiltInTemplateDirectory = (templateName) => { + if (templateName === 'basic') { + return templates.templateWithList + } + + if (templateName === 'react-router') { + return templates.templateWithReactRouter + } + + return null } const command = { @@ -86,7 +94,7 @@ const command = { typeScript: argv.typescript ?? true, packageManager: argv.packageManager ?? getPackageManager() ?? 'pnpm', - templateName: argv.template ?? 'basic', + templateSource: argv.template ?? 'basic', } if (!useDefauls) { @@ -106,17 +114,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)', + required: true, + }) + } else { + selectedOptions.templateSource = template + } } } @@ -158,8 +178,30 @@ const command = { } reporter.info('Copying template files') - const templateFiles = getTemplateDirectory(selectedOptions.templateName) - fs.copySync(templateFiles, cwd) + let resolvedExternalTemplate + try { + const builtInTemplatePath = getBuiltInTemplateDirectory( + selectedOptions.templateSource + ) + + 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 (resolvedExternalTemplate) { + await resolvedExternalTemplate.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..c58ad982 --- /dev/null +++ b/packages/create-app/src/utils/isGitTemplateSpecifier.js @@ -0,0 +1,162 @@ +const githubHosts = new Set(['github.com', 'www.github.com']) +const ownerPattern = /^[a-zA-Z0-9_.-]+$/ + +const parseRef = (rawTemplateSource, refPart) => { + if (refPart === undefined) { + return null + } + if (!refPart) { + throw new Error( + `Invalid template source "${rawTemplateSource}". Ref cannot be empty after "#".` + ) + } + if (refPart.includes(':')) { + throw new Error( + `Invalid template source "${rawTemplateSource}". Use "owner/repo" or "owner/repo#ref".` + ) + } + + return refPart +} + +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 separatorIndex = sourceWithoutRef.indexOf('/') + const hasSingleSeparator = + separatorIndex > 0 && + separatorIndex === sourceWithoutRef.lastIndexOf('/') + if (!hasSingleSeparator) { + throw new Error( + `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, + repo, + } +} + +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) { + throw new Error('Template source cannot be empty.') + } + + 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 = parseRef(rawTemplateSource, refPart) + 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, + 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 + } + + const [sourceWithoutRef, refPart, ...rest] = rawTemplateSource.split('#') + if (rest.length > 0 || !isValidRefPart(refPart)) { + return false + } + + return isValidShorthandSource(sourceWithoutRef) +} + +module.exports = { + isGitTemplateSpecifier, + parseGitTemplateSpecifier, +} 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/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' + ) + } +})