From 691630f00d28f3313816e84df113dd9962c4eeb2 Mon Sep 17 00:00:00 2001 From: Derrick Nuby Date: Tue, 24 Feb 2026 16:00:41 +0200 Subject: [PATCH] feat(create-app): add community templates support --- docs/commands/create-app.md | 10 +- packages/create-app/package.json | 3 +- .../create-app/src/community-templates.yaml | 10 ++ packages/create-app/src/index.js | 92 +++++++++- .../src/utils/getCommunityTemplates.js | 159 ++++++++++++++++++ .../src/utils/resolveTemplateSourceInput.js | 27 +++ .../tests/is-git-template-specifier.js | 89 ---------- .../tests/resolve-external-template-source.js | 69 -------- pnpm-lock.yaml | 41 +++-- 9 files changed, 318 insertions(+), 182 deletions(-) create mode 100644 packages/create-app/src/community-templates.yaml create mode 100644 packages/create-app/src/utils/getCommunityTemplates.js create mode 100644 packages/create-app/src/utils/resolveTemplateSourceInput.js delete mode 100644 packages/create-app/tests/is-git-template-specifier.js delete 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 fd2a6dce..fe345e34 100644 --- a/docs/commands/create-app.md +++ b/docs/commands/create-app.md @@ -42,12 +42,15 @@ 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, or GitHub - template specifier) [string] + React Router, community template + source, or GitHub template + specifier) [string] --packageManager, --package, Package Manager --packagemanager [string] ``` +In interactive mode, template selection includes built-in templates, configured community templates, and a `Custom template from Git` option. For non-interactive usage, `--template` accepts built-in values (`basic`, `react-router`), configured community template sources, and direct GitHub specifiers. + ## Examples Here are some examples of how you can use the CLI @@ -59,6 +62,9 @@ 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 configured community template source +pnpm create @dhis2/app my-app --template derrick-nuby/dhis2-ts-tailwind-react-router + # use a custom template from GitHub (owner/repo) pnpm create @dhis2/app my-app --template owner/repo diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 3d0b3333..e2a8a86b 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -13,7 +13,8 @@ "@dhis2/cli-helpers-engine": "^3.2.2", "@inquirer/prompts": "^7.8.4", "fast-glob": "^3.3.3", - "fs-extra": "^11.3.3" + "fs-extra": "^11.3.3", + "yaml": "^2.8.1" }, "private": false, "keywords": [], diff --git a/packages/create-app/src/community-templates.yaml b/packages/create-app/src/community-templates.yaml new file mode 100644 index 00000000..fcb51bf1 --- /dev/null +++ b/packages/create-app/src/community-templates.yaml @@ -0,0 +1,10 @@ +templates: + - name: Tailwind + React Router + description: React Router DHIS2 app with Tailwind CSS + source: derrick-nuby/dhis2-ts-tailwind-react-router + maintainer: + name: Derrick Iradukunda + url: https://github.com/derrick-nuby + organisation: + name: HISP Rwanda + url: https://hisprwanda.org diff --git a/packages/create-app/src/index.js b/packages/create-app/src/index.js index 0db9539c..f24d0074 100644 --- a/packages/create-app/src/index.js +++ b/packages/create-app/src/index.js @@ -1,11 +1,16 @@ -const { execSync } = require('child_process') -const path = require('path') +const { execSync } = require('node:child_process') +const path = require('node:path') const { reporter, exec, chalk } = require('@dhis2/cli-helpers-engine') const { input, select } = require('@inquirer/prompts') const fg = require('fast-glob') const fs = require('fs-extra') +const getCommunityTemplates = require('./utils/getCommunityTemplates') const { default: getPackageManager } = require('./utils/getPackageManager') +const { isGitTemplateSpecifier } = require('./utils/isGitTemplateSpecifier') const resolveExternalTemplateSource = require('./utils/resolveExternalTemplateSource') +const { + resolveTemplateSourceInput, +} = require('./utils/resolveTemplateSourceInput') process.on('uncaughtException', (error) => { if (error instanceof Error && error.name === 'ExitPromptError') { @@ -28,6 +33,7 @@ const templates = { '../templates/template-ts-dataelements-react-router' ), } +const builtInTemplateKeys = ['basic', 'react-router'] const commandHandler = { command: '*', // default command @@ -47,7 +53,7 @@ const commandHandler = { }, template: { description: - 'Which template to use (Basic, With React Router, or GitHub template specifier)', + 'Which template to use (basic, react-router, community template source, or GitHub template specifier)', type: 'string', }, packageManager: { @@ -70,6 +76,18 @@ const getBuiltInTemplateDirectory = (templateName) => { return null } +const formatUnknownTemplateError = (templateSource, communityTemplates) => { + const communitySources = communityTemplates.map( + (template) => template.source + ) + const availableTemplates = [...builtInTemplateKeys, ...communitySources] + const formattedTemplateList = availableTemplates.length + ? availableTemplates.join(', ') + : '(none)' + + return `Unknown template "${templateSource}". Available templates: ${formattedTemplateList}. Or use a GitHub template specifier like "owner/repo#ref" or "https://github.com/owner/repo#ref".` +} + const command = { command: '[app]', builder: (yargs) => { @@ -77,6 +95,16 @@ const command = { }, handler: async (argv) => { let name = argv._[0] || argv.name + let communityTemplates = [] + + try { + communityTemplates = getCommunityTemplates() + } catch (error) { + reporter.error( + error instanceof Error ? error.message : String(error) + ) + process.exit(1) + } const useDefauls = argv.yes @@ -121,6 +149,10 @@ const command = { name: 'Template with React Router', value: 'react-router', }, + { + name: 'Community templates', + value: 'community', + }, { name: 'Custom template from Git', value: 'custom-git', @@ -134,6 +166,30 @@ const command = { 'Enter GitHub template specifier (e.g. owner/repo#main)', required: true, }) + } else if (template === 'community') { + const communityTemplate = await select({ + message: 'Select a community template', + choices: [ + ...communityTemplates.map((communityEntry) => ({ + name: communityEntry.displayName, + value: communityEntry.source, + })), + { + name: 'Use a Git repository', + value: 'custom-git', + }, + ], + }) + + if (communityTemplate === 'custom-git') { + selectedOptions.templateSource = await input({ + message: + 'Enter GitHub template specifier (e.g. owner/repo#main)', + required: true, + }) + } else { + selectedOptions.templateSource = communityTemplate + } } else { selectedOptions.templateSource = template } @@ -174,21 +230,45 @@ const command = { }) reporter.debug('Successfully ran git clean') } catch (err) { - reporter.debug(err) + reporter.debug(err instanceof Error ? err.message : String(err)) } reporter.info('Copying template files') let resolvedExternalTemplate try { + const normalizedTemplateSource = String( + selectedOptions.templateSource || '' + ).trim() const builtInTemplatePath = getBuiltInTemplateDirectory( - selectedOptions.templateSource + normalizedTemplateSource ) if (builtInTemplatePath) { fs.copySync(builtInTemplatePath, cwd) } else { + const resolvedTemplateSource = resolveTemplateSourceInput( + normalizedTemplateSource, + communityTemplates + ) + const externalTemplateSource = resolvedTemplateSource.source + + if (!isGitTemplateSpecifier(externalTemplateSource)) { + if (resolvedTemplateSource.kind === 'community') { + throw new Error( + `Community template "${resolvedTemplateSource.name}" has an invalid source "${externalTemplateSource}".` + ) + } + + throw new Error( + formatUnknownTemplateError( + normalizedTemplateSource, + communityTemplates + ) + ) + } + resolvedExternalTemplate = await resolveExternalTemplateSource( - selectedOptions.templateSource + externalTemplateSource ) fs.copySync(resolvedExternalTemplate.templatePath, cwd) } diff --git a/packages/create-app/src/utils/getCommunityTemplates.js b/packages/create-app/src/utils/getCommunityTemplates.js new file mode 100644 index 00000000..9d0f2386 --- /dev/null +++ b/packages/create-app/src/utils/getCommunityTemplates.js @@ -0,0 +1,159 @@ +const path = require('node:path') +const fs = require('fs-extra') +const yaml = require('yaml') + +const defaultRegistryPath = path.join(__dirname, '../community-templates.yaml') + +const getRequiredString = (value, fieldPath, registryPath) => { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + `Invalid community template registry "${registryPath}": "${fieldPath}" must be a non-empty string.` + ) + } + + return value.trim() +} + +const getOptionalString = (value, fieldPath, registryPath) => { + if (value === undefined || value === null) { + return null + } + + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + `Invalid community template registry "${registryPath}": "${fieldPath}" must be a non-empty string when provided.` + ) + } + + return value.trim() +} + +const getOptionalPersonMetadata = (value, fieldPath, registryPath) => { + if (value === undefined || value === null) { + return null + } + + if (typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError( + `Invalid community template registry "${registryPath}": "${fieldPath}" must be an object when provided.` + ) + } + + const name = getOptionalString( + value.name, + `${fieldPath}.name`, + registryPath + ) + const url = getOptionalString(value.url, `${fieldPath}.url`, registryPath) + + if (!name && !url) { + return null + } + + return { + ...(name ? { name } : {}), + ...(url ? { url } : {}), + } +} + +const getCommunityTemplates = (registryPath = defaultRegistryPath) => { + let registryFileContent + + try { + registryFileContent = fs.readFileSync(registryPath, 'utf8') + } catch (error) { + const detail = + error instanceof Error && error.message ? ` ${error.message}` : '' + throw new Error( + `Failed to read community template registry "${registryPath}".${detail}` + ) + } + + let parsedRegistry + try { + parsedRegistry = yaml.parse(registryFileContent) + } catch (error) { + const detail = + error instanceof Error && error.message ? ` ${error.message}` : '' + throw new Error( + `Failed to parse community template registry "${registryPath}".${detail}` + ) + } + + if (!parsedRegistry || !Array.isArray(parsedRegistry.templates)) { + throw new Error( + `Invalid community template registry "${registryPath}": "templates" must be an array.` + ) + } + + const knownTemplateSources = new Set() + + return parsedRegistry.templates.map((template, index) => { + const templateFieldPrefix = `templates[${index}]` + if ( + !template || + typeof template !== 'object' || + Array.isArray(template) + ) { + throw new Error( + `Invalid community template registry "${registryPath}": "${templateFieldPrefix}" must be an object.` + ) + } + + const name = getRequiredString( + template.name, + `${templateFieldPrefix}.name`, + registryPath + ) + const source = getRequiredString( + template.source, + `${templateFieldPrefix}.source`, + registryPath + ) + + if (knownTemplateSources.has(source)) { + throw new Error( + `Invalid community template registry "${registryPath}": duplicate template source "${source}" in "${templateFieldPrefix}.source".` + ) + } + knownTemplateSources.add(source) + + const description = getOptionalString( + template.description, + `${templateFieldPrefix}.description`, + registryPath + ) + const maintainer = getOptionalPersonMetadata( + template.maintainer, + `${templateFieldPrefix}.maintainer`, + registryPath + ) + const organisation = getOptionalPersonMetadata( + template.organisation, + `${templateFieldPrefix}.organisation`, + registryPath + ) + + const attributionParts = [] + if (maintainer?.name) { + attributionParts.push(`by ${maintainer.name}`) + } + if (organisation?.name) { + attributionParts.push(`org: ${organisation.name}`) + } + const displayName = attributionParts.length + ? `${name} (${attributionParts.join(', ')})` + : name + + return { + name, + source, + ...(description ? { description } : {}), + ...(maintainer ? { maintainer } : {}), + ...(organisation ? { organisation } : {}), + displayName, + } + }) +} + +module.exports = getCommunityTemplates diff --git a/packages/create-app/src/utils/resolveTemplateSourceInput.js b/packages/create-app/src/utils/resolveTemplateSourceInput.js new file mode 100644 index 00000000..758e1ba1 --- /dev/null +++ b/packages/create-app/src/utils/resolveTemplateSourceInput.js @@ -0,0 +1,27 @@ +const resolveTemplateSourceInput = ( + templateSource, + communityTemplates = [] +) => { + const normalizedTemplateSource = String(templateSource || '').trim() + + const matchedCommunityTemplate = communityTemplates.find( + (communityTemplate) => + communityTemplate.source === normalizedTemplateSource + ) + if (matchedCommunityTemplate) { + return { + kind: 'community', + name: matchedCommunityTemplate.name, + source: matchedCommunityTemplate.source, + } + } + + return { + kind: 'external', + source: normalizedTemplateSource, + } +} + +module.exports = { + resolveTemplateSourceInput, +} diff --git a/packages/create-app/tests/is-git-template-specifier.js b/packages/create-app/tests/is-git-template-specifier.js deleted file mode 100644 index f552860f..00000000 --- a/packages/create-app/tests/is-git-template-specifier.js +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index dcb81d14..00000000 --- a/packages/create-app/tests/resolve-external-template-source.js +++ /dev/null @@ -1,69 +0,0 @@ -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' - ) - } -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a29c03d..78467afd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,15 +56,18 @@ importers: fs-extra: specifier: ^11.3.3 version: 11.3.3 + yaml: + specifier: ^2.8.1 + version: 2.8.2 packages/main: dependencies: '@dhis2/cli-app': - specifier: 5.3.1 - version: 5.3.1(typescript@5.8.3) + specifier: 5.4.0 + version: 5.4.0(typescript@5.8.3) '@dhis2/cli-cluster': - specifier: 5.3.1 - version: 5.3.1 + specifier: 5.4.0 + version: 5.4.0 '@dhis2/cli-helpers-engine': specifier: ^3.2.1 version: 3.2.2 @@ -72,8 +75,8 @@ importers: specifier: ^10.7.9 version: 10.7.9 '@dhis2/cli-utils': - specifier: 5.3.1 - version: 5.3.1(typescript@5.8.3) + specifier: 5.4.0 + version: 5.4.0(typescript@5.8.3) cli-table3: specifier: ^0.6.0 version: 0.6.5 @@ -1370,13 +1373,13 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true - '@dhis2/cli-app@5.3.1': - resolution: {integrity: sha512-BZHliDwvDWxgfnSdreDI+DV2EDMPB/rZhsYBRxm1qfZ65+y/uGPPJAT17gq0iH3Vu6vs2r4hmfN9VMbdWZK/jA==} + '@dhis2/cli-app@5.4.0': + resolution: {integrity: sha512-u7uOvZkWqDprcLwote3vS3fr8y9QkQjjzhPs7bIEDEdWMpUJfvQz4axuH1TKPta6I2Xv4xwwwQq3MBH3ad8d/Q==} engines: {node: '>=12'} hasBin: true - '@dhis2/cli-cluster@5.3.1': - resolution: {integrity: sha512-8carthbPbITkwCPwtS731MoIh3twuvot6LzWztKRWOAziEnrwO9ceD8Mfjar2ulG+pysJDSATR1sT63AwUP7Ow==} + '@dhis2/cli-cluster@5.4.0': + resolution: {integrity: sha512-0uScwwUl0JZOUzFvqqJcRiqtWuvgsUlrMfRMLZoIMQKSnOtmYI3z7s5BLIO5JKkYnMHoDS0N0LoR9oh99kKuew==} engines: {node: '>=12'} hasBin: true @@ -1415,8 +1418,8 @@ packages: engines: {node: '>=12'} hasBin: true - '@dhis2/cli-utils@5.3.1': - resolution: {integrity: sha512-jEkhvU+0hmGi6kcUvGh/s0+xLezGqj4z3RygwvyEJcGi7NyheLeEZ2NZ+hlUD/t1a655sWqrosmW1FVKxSoGnA==} + '@dhis2/cli-utils@5.4.0': + resolution: {integrity: sha512-DqJhb/DzXx4/k8ChEEsQDxhExseHa3GtUX8CS/YnAm++UpPlk3b6+lhNOLB0xDxaBobgltLBRLr9W6tuXRrFbQ==} engines: {node: '>=12'} hasBin: true @@ -5492,6 +5495,7 @@ packages: resolution: {integrity: sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw==} engines: {node: '>=8.17.0'} hasBin: true + bundledDependencies: [] jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -8314,6 +8318,11 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@13.1.2: resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} @@ -10803,7 +10812,7 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@dhis2/cli-app@5.3.1(typescript@5.8.3)': + '@dhis2/cli-app@5.4.0(typescript@5.8.3)': dependencies: '@dhis2/cli-app-scripts': 12.11.0-alpha.1(@types/babel__core@7.20.5)(@types/node@24.0.4)(react@18.3.1)(terser@5.43.1)(type-fest@4.41.0)(typescript@5.8.3)(webpack-dev-server@4.15.2(webpack@5.100.0)) '@dhis2/cli-helpers-engine': 3.2.2 @@ -10839,7 +10848,7 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@dhis2/cli-cluster@5.3.1': + '@dhis2/cli-cluster@5.4.0': dependencies: '@dhis2/cli-helpers-engine': 3.2.2 cli-table3: 0.6.5 @@ -10959,7 +10968,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@dhis2/cli-utils@5.3.1(typescript@5.8.3)': + '@dhis2/cli-utils@5.4.0(typescript@5.8.3)': dependencies: '@dhis2/cli-helpers-engine': 3.2.2 '@dhis2/cli-utils-codemods': 3.0.0(@babel/preset-env@7.28.0(@babel/core@7.27.7)) @@ -19094,6 +19103,8 @@ snapshots: yaml@1.10.2: {} + yaml@2.8.2: {} + yargs-parser@13.1.2: dependencies: camelcase: 5.3.1