diff --git a/.eslintrc.yml b/.eslintrc.yml index 37194ee8..4b157d6b 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,3 +1,6 @@ extends: - '@form8ion' - '@form8ion/cucumber' + +parserOptions: + ecmaVersion: 2022 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76e4e582..44746e91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,11 @@ permissions: contents: read jobs: release: - uses: >- - form8ion/.github/.github/workflows/release-package-semantic-release-19.yml@d7062208039222450ac7926b68f3a30d32285f26 + permissions: + contents: write + id-token: write + issues: write + pull-requests: write + uses: form8ion/.github/.github/workflows/release-package.yml@master secrets: NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} diff --git a/.husky/commit-msg b/.husky/commit-msg index 314e8214..fd2bf708 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx --no-install commitlint --edit $1 \ No newline at end of file +npx --no-install commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit index 0396fa5e..72c4429b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npm test \ No newline at end of file +npm test diff --git a/.remarkrc.cjs b/.remarkrc.cjs index b79c1995..6d8b2bba 100644 --- a/.remarkrc.cjs +++ b/.remarkrc.cjs @@ -1,5 +1,5 @@ exports.settings = { - listItemIndent: 1, + listItemIndent: 'one', emphasis: '_', strong: '_', bullet: '*', diff --git a/README.md b/README.md index 96f594b2..a9196968 100644 --- a/README.md +++ b/README.md @@ -77,28 +77,39 @@ import {lift, questionNames, scaffold} from '@form8ion/project'; #### Execute ```javascript - await scaffold({ - decisions: { - [questionNames.PROJECT_NAME]: 'my-project', - [questionNames.LICENSE]: 'MIT', - [questionNames.VISIBILITY]: 'Public', - [questionNames.DESCRIPTION]: 'My project', - [questionNames.GIT_REPO]: false, - [questionNames.COPYRIGHT_HOLDER]: 'John Smith', - [questionNames.COPYRIGHT_YEAR]: '2022', - [questionNames.PROJECT_LANGUAGE]: 'foo' +await scaffold({ + decisions: { + [questionNames.PROJECT_NAME]: 'my-project', + [questionNames.LICENSE]: 'MIT', + [questionNames.VISIBILITY]: 'Public', + [questionNames.DESCRIPTION]: 'My project', + [questionNames.GIT_REPO]: false, + [questionNames.COPYRIGHT_HOLDER]: 'John Smith', + [questionNames.COPYRIGHT_YEAR]: '2022', + [questionNames.PROJECT_LANGUAGE]: 'foo' + }, + plugins: { + dependencyUpdaters: { + bar: {scaffold: options => options} }, languages: { - foo: options => options + foo: {scaffold: options => options} + }, + vcsHosts: { + baz: { + scaffold: options => options, + prompt: () => ({repoOwner: 'form8ion'}) + } } - }); - - await lift({ - projectRoot: process.cwd(), - results: {}, - enhancers: {foo: {test: () => true, lift: () => ({})}}, - vcs: {} - }); + } +}); + +await lift({ + projectRoot: process.cwd(), + results: {}, + enhancers: {foo: {test: () => true, lift: () => ({})}}, + vcs: {} +}); ``` ### API diff --git a/example.js b/example.js index 1735be27..e8be7092 100644 --- a/example.js +++ b/example.js @@ -7,31 +7,42 @@ import {lift, questionNames, scaffold} from './lib/index.js'; // #### Execute -// remark-usage-ignore-next 2 -(async () => { - stubbedFs({templates: {'editorconfig.ini': await fs.readFile(resolve('templates', 'editorconfig.ini'))}}); +// remark-usage-ignore-next 4 +stubbedFs({ + templates: {'editorconfig.ini': await fs.readFile(resolve('templates', 'editorconfig.ini'))}, + node_modules: stubbedFs.load('node_modules') +}); - await scaffold({ - decisions: { - [questionNames.PROJECT_NAME]: 'my-project', - [questionNames.LICENSE]: 'MIT', - [questionNames.VISIBILITY]: 'Public', - [questionNames.DESCRIPTION]: 'My project', - [questionNames.GIT_REPO]: false, - [questionNames.COPYRIGHT_HOLDER]: 'John Smith', - [questionNames.COPYRIGHT_YEAR]: '2022', - [questionNames.PROJECT_LANGUAGE]: 'foo' +await scaffold({ + decisions: { + [questionNames.PROJECT_NAME]: 'my-project', + [questionNames.LICENSE]: 'MIT', + [questionNames.VISIBILITY]: 'Public', + [questionNames.DESCRIPTION]: 'My project', + [questionNames.GIT_REPO]: false, + [questionNames.COPYRIGHT_HOLDER]: 'John Smith', + [questionNames.COPYRIGHT_YEAR]: '2022', + [questionNames.PROJECT_LANGUAGE]: 'foo' + }, + plugins: { + dependencyUpdaters: { + bar: {scaffold: options => options} }, languages: { - foo: options => options + foo: {scaffold: options => options} + }, + vcsHosts: { + baz: { + scaffold: options => options, + prompt: () => ({repoOwner: 'form8ion'}) + } } - }); + } +}); - await lift({ - projectRoot: process.cwd(), - results: {}, - enhancers: {foo: {test: () => true, lift: () => ({})}}, - vcs: {} - }); -// remark-usage-ignore-next -})(); +await lift({ + projectRoot: process.cwd(), + results: {}, + enhancers: {foo: {test: () => true, lift: () => ({})}}, + vcs: {} +}); diff --git a/package-lock.json b/package-lock.json index 7cf3e5b4..b6504571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-semantically-released", "license": "MIT", "dependencies": { - "@form8ion/core": "^4.3.0", + "@form8ion/core": "^4.6.0", "@form8ion/execa-wrapper": "^1.0.0", "@form8ion/git": "^1.2.0", "@form8ion/overridable-prompts": "^1.1.0", diff --git a/package.json b/package.json index 49f7cef9..403ac382 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ } }, "dependencies": { - "@form8ion/core": "^4.3.0", + "@form8ion/core": "^4.6.0", "@form8ion/execa-wrapper": "^1.0.0", "@form8ion/git": "^1.2.0", "@form8ion/overridable-prompts": "^1.1.0", diff --git a/src/dependency-updater/scaffolder.js b/src/dependency-updater/scaffolder.js index 78fded2d..da03aeb4 100644 --- a/src/dependency-updater/scaffolder.js +++ b/src/dependency-updater/scaffolder.js @@ -1,14 +1,14 @@ import {questionNames} from '../prompts/question-names.js'; import {promptForDependencyUpdaterChoice} from './prompt.js'; -export default async function (scaffolders, decisions, options) { - if (!Object.keys(scaffolders).length) return undefined; +export default async function (plugins, decisions, options) { + if (!Object.keys(plugins).length) return undefined; - const scaffolderDetails = scaffolders[ - (await promptForDependencyUpdaterChoice(scaffolders, decisions))[questionNames.DEPENDENCY_UPDATER] + const plugin = plugins[ + (await promptForDependencyUpdaterChoice(plugins, decisions))[questionNames.DEPENDENCY_UPDATER] ]; - if (scaffolderDetails) return scaffolderDetails.scaffolder(options); + if (plugin) return plugin.scaffold(options); return undefined; } diff --git a/src/dependency-updater/scaffolder.test.js b/src/dependency-updater/scaffolder.test.js index 4d47cea7..cbbed534 100644 --- a/src/dependency-updater/scaffolder.test.js +++ b/src/dependency-updater/scaffolder.test.js @@ -19,14 +19,14 @@ describe('dependency-updater scaffolder', () => { const options = any.simpleObject(); const chosenUpdater = any.word(); const chosenUpdaterScaffolder = vi.fn(); - const scaffolders = {...any.simpleObject(), [chosenUpdater]: {scaffolder: chosenUpdaterScaffolder}}; + const plugins = {...any.simpleObject(), [chosenUpdater]: {scaffold: chosenUpdaterScaffolder}}; const scaffolderResult = any.simpleObject(); when(prompt.promptForDependencyUpdaterChoice) - .calledWith(scaffolders, decisions) + .calledWith(plugins, decisions) .mockResolvedValue({[questionNames.DEPENDENCY_UPDATER]: chosenUpdater}); when(chosenUpdaterScaffolder).calledWith(options).mockResolvedValue(scaffolderResult); - expect(await scaffoldUpdater(scaffolders, decisions, options)).toEqual(scaffolderResult); + expect(await scaffoldUpdater(plugins, decisions, options)).toEqual(scaffolderResult); }); it('should not present a prompt if no updaters are registered', async () => { diff --git a/src/dependency-updater/schema.js b/src/dependency-updater/schema.js index ee074293..90e61ec2 100644 --- a/src/dependency-updater/schema.js +++ b/src/dependency-updater/schema.js @@ -1,5 +1,4 @@ import joi from 'joi'; +import {optionsSchemas} from '@form8ion/core'; -export default joi.object().pattern(/^/, joi.object({ - scaffolder: joi.func().arity(1).required() -})).default({}); +export default joi.object().pattern(/^/, optionsSchemas.form8ionPlugin).default({}); diff --git a/src/dependency-updater/schema.test.js b/src/dependency-updater/schema.test.js index 2a93eaae..58c47ce4 100644 --- a/src/dependency-updater/schema.test.js +++ b/src/dependency-updater/schema.test.js @@ -11,7 +11,7 @@ describe('dependency-updater plugins schema', () => { it('should return the validated options', () => { const options = any.objectWithKeys( any.listOf(any.string), - {factory: () => ({scaffolder: foo => foo})} + {factory: () => ({scaffold: foo => foo})} ); expect(validateOptions(dependencyUpdaterPluginsSchema, options)).toEqual(options); @@ -22,19 +22,19 @@ describe('dependency-updater plugins schema', () => { .toThrowError(`"${key}" must be of type object`); }); - it('should require a `scaffolder` to be included', () => { + it('should require a `scaffold` property to be included', () => { expect(() => validateOptions(dependencyUpdaterPluginsSchema, {[key]: {}})) - .toThrowError(`"${key}.scaffolder" is required`); + .toThrowError(`"${key}.scaffold" is required`); }); - it('should require `scaffolder` to be a function', () => { - expect(() => validateOptions(dependencyUpdaterPluginsSchema, {[key]: {scaffolder: any.word()}})) - .toThrowError(`"${key}.scaffolder" must be of type function`); + it('should require `scaffold` to be a function', () => { + expect(() => validateOptions(dependencyUpdaterPluginsSchema, {[key]: {scaffold: any.word()}})) + .toThrowError(`"${key}.scaffold" must be of type function`); }); it('should require the scaffolder to accept a single argument', () => { - expect(() => validateOptions(dependencyUpdaterPluginsSchema, {[key]: {scaffolder: () => undefined}})) - .toThrowError(`"${key}.scaffolder" must have an arity of 1`); + expect(() => validateOptions(dependencyUpdaterPluginsSchema, {[key]: {scaffold: () => undefined}})) + .toThrowError(`"${key}.scaffold" must have an arity of 1`); }); it('should default to an empty map when no updaters are provided', () => { diff --git a/src/language/scaffolder.js b/src/language/scaffolder.js index 346a1943..96600963 100644 --- a/src/language/scaffolder.js +++ b/src/language/scaffolder.js @@ -1,7 +1,12 @@ -export default function (scaffolders, chosenLanguage, options) { - const scaffolder = scaffolders[chosenLanguage]; +import {questionNames} from '../prompts/question-names.js'; +import promptForLanguageDetails from './prompt.js'; - if (scaffolder) return scaffolder(options); +export default async function (languagePlugins, decisions, options) { + const {[questionNames.PROJECT_LANGUAGE]: chosenLanguage} = await promptForLanguageDetails(languagePlugins, decisions); + + const plugin = languagePlugins[chosenLanguage]; + + if (plugin) return plugin.scaffold(options); return undefined; } diff --git a/src/language/scaffolder.test.js b/src/language/scaffolder.test.js index b2a68775..6370d7a0 100644 --- a/src/language/scaffolder.test.js +++ b/src/language/scaffolder.test.js @@ -2,21 +2,31 @@ import {describe, expect, it, vi} from 'vitest'; import any from '@travi/any'; import {when} from 'jest-when'; +import * as languagePrompt from './prompt.js'; +import {questionNames} from '../prompts/question-names.js'; import scaffold from './scaffolder.js'; +vi.mock('./prompt.js'); + describe('language scaffolder', () => { it('should scaffold the chosen language', async () => { const options = any.simpleObject(); const chosenLanguage = any.word(); const scaffolderResult = any.simpleObject(); + const decisions = any.simpleObject(); const chosenLanguageScaffolder = vi.fn(); - const scaffolders = {...any.simpleObject(), [chosenLanguage]: chosenLanguageScaffolder}; + const plugins = {...any.simpleObject(), [chosenLanguage]: {scaffold: chosenLanguageScaffolder}}; + when(languagePrompt.default) + .calledWith(plugins, decisions) + .mockResolvedValue({[questionNames.PROJECT_LANGUAGE]: chosenLanguage}); when(chosenLanguageScaffolder).calledWith(options).mockResolvedValue(scaffolderResult); - expect(await scaffold(scaffolders, chosenLanguage, options)).toEqual(scaffolderResult); + expect(await scaffold(plugins, decisions, options)).toEqual(scaffolderResult); }); it('should not result in an error when choosing a language without a defined scaffolder', async () => { - await scaffold(any.simpleObject(), any.word(), any.simpleObject()); + when(languagePrompt.default).mockResolvedValue({[questionNames.PROJECT_LANGUAGE]: any.word()}); + + await scaffold(any.simpleObject(), any.simpleObject(), any.simpleObject()); }); }); diff --git a/src/language/schema.js b/src/language/schema.js index 7874006e..90e61ec2 100644 --- a/src/language/schema.js +++ b/src/language/schema.js @@ -1,3 +1,4 @@ import joi from 'joi'; +import {optionsSchemas} from '@form8ion/core'; -export default joi.object().pattern(/^/, joi.func().arity(1)); +export default joi.object().pattern(/^/, optionsSchemas.form8ionPlugin).default({}); diff --git a/src/language/schema.test.js b/src/language/schema.test.js index 10ad280f..ef521481 100644 --- a/src/language/schema.test.js +++ b/src/language/schema.test.js @@ -9,18 +9,35 @@ describe('language plugins schema', () => { const key = any.word(); it('should return the validated options', () => { - const options = any.objectWithKeys(any.listOf(any.string), {factory: () => foo => foo}); + const options = any.objectWithKeys( + any.listOf(any.string), + {factory: () => ({scaffold: foo => foo})} + ); expect(validateOptions(languageSchema, options)).toEqual(options); }); - it('should require a scaffold function to be included', () => { - expect(() => validateOptions(languageSchema, {[key]: any.word()})) - .toThrowError(`"${key}" must be of type function`); + it('should require options to be provided as an object', () => { + expect(() => validateOptions(languageSchema, {[key]: []})) + .toThrowError(`"${key}" must be of type object`); + }); + + it('should require a `scaffold` property to be included', () => { + expect(() => validateOptions(languageSchema, {[key]: {}})) + .toThrowError(`"${key}.scaffold" is required`); + }); + + it('should require `scaffold` to be a function', () => { + expect(() => validateOptions(languageSchema, {[key]: {scaffold: any.word()}})) + .toThrowError(`"${key}.scaffold" must be of type function`); }); it('should require the scaffolder to accept a single argument', () => { - expect(() => validateOptions(languageSchema, {[key]: () => undefined})) - .toThrowError(`"${key}" must have an arity of 1`); + expect(() => validateOptions(languageSchema, {[key]: {scaffold: () => undefined}})) + .toThrowError(`"${key}.scaffold" must have an arity of 1`); + }); + + it('should default to an empty map when no updaters are provided', () => { + expect(validateOptions(languageSchema)).toEqual({}); }); }); diff --git a/src/options-validator.js b/src/options-validator.js index 40192e5b..348e9e5f 100644 --- a/src/options-validator.js +++ b/src/options-validator.js @@ -8,9 +8,11 @@ import {decisionsSchema} from './options-schemas.js'; export function validate(options) { return validateOptions(joi.object({ - languages: languagePluginsSchema, - vcsHosts: vcsHostPluginsSchema, decisions: decisionsSchema, - dependencyUpdaters: dependencyUpdaterPluginsSchema + plugins: joi.object({ + dependencyUpdaters: dependencyUpdaterPluginsSchema, + languages: languagePluginsSchema, + vcsHosts: vcsHostPluginsSchema + }) }), options) || {}; } diff --git a/src/options-validator.test.js b/src/options-validator.test.js index 8375e142..72925473 100644 --- a/src/options-validator.test.js +++ b/src/options-validator.test.js @@ -1,7 +1,7 @@ import joi from 'joi'; -import {validateOptions} from '@form8ion/core'; +import * as core from '@form8ion/core'; -import {describe, expect, it, beforeEach, afterEach, vi} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import any from '@travi/any'; import {when} from 'jest-when'; @@ -11,11 +11,16 @@ import vcsHostPluginsSchema from './vcs/host/schema.js'; import dependencyUpdaterPluginsSchema from './dependency-updater/schema.js'; import {validate} from './options-validator.js'; -vi.mock('@form8ion/core'); +vi.mock('@form8ion/core', async () => ({ + validateOptions: vi.fn(), + optionsSchemas: {form8ionPlugin: joi.object()} +})); +vi.mock('./vcs/host/schema.js'); describe('options validator', () => { beforeEach(() => { vi.spyOn(joi, 'object'); + vi.spyOn(joi, 'string'); }); afterEach(() => { @@ -24,15 +29,21 @@ describe('options validator', () => { it('should build the full schema and call the base validator', () => { const options = any.simpleObject(); + const pluginsSchema = any.simpleObject(); const fullSchema = any.simpleObject(); const validatedOptions = any.simpleObject(); + when(joi.object) + .calledWith({ + dependencyUpdaters: dependencyUpdaterPluginsSchema, + languages: languagePluginsSchema, + vcsHosts: vcsHostPluginsSchema + }) + .mockReturnValue(pluginsSchema); when(joi.object).calledWith({ - languages: languagePluginsSchema, - vcsHosts: vcsHostPluginsSchema, decisions: decisionsSchema, - dependencyUpdaters: dependencyUpdaterPluginsSchema + plugins: pluginsSchema }).mockReturnValue(fullSchema); - when(validateOptions).calledWith(fullSchema, options).mockReturnValue(validatedOptions); + when(core.validateOptions).calledWith(fullSchema, options).mockReturnValue(validatedOptions); expect(validate(options)).toEqual(validatedOptions); }); diff --git a/src/prompts/conditionals.js b/src/prompts/conditionals.js index 72e6fa02..359a1d2f 100644 --- a/src/prompts/conditionals.js +++ b/src/prompts/conditionals.js @@ -1,5 +1,3 @@ -import {Separator} from '@form8ion/overridable-prompts'; - import {questionNames} from './question-names.js'; export function unlicensedConfirmationShouldBePresented(answers) { @@ -13,13 +11,3 @@ export function licenseChoicesShouldBePresented(answers) { export function copyrightInformationShouldBeRequested(answers) { return !!answers[questionNames.LICENSE]; } - -export function filterChoicesByVisibility(choices, visibility) { - return [ - ...Object.entries(choices) - .filter(([, choice]) => choice[visibility.toLowerCase()]) - .reduce((acc, [name]) => ([...acc, name]), []), - new Separator(), - 'Other' - ]; -} diff --git a/src/prompts/conditionals.test.js b/src/prompts/conditionals.test.js index b95b04c5..9a40395e 100644 --- a/src/prompts/conditionals.test.js +++ b/src/prompts/conditionals.test.js @@ -1,12 +1,9 @@ -import {Separator} from '@form8ion/overridable-prompts'; - import {describe, expect, it} from 'vitest'; import any from '@travi/any'; import {questionNames} from './question-names.js'; import { copyrightInformationShouldBeRequested, - filterChoicesByVisibility, licenseChoicesShouldBePresented, unlicensedConfirmationShouldBePresented } from './conditionals.js'; @@ -51,32 +48,4 @@ describe('prompt conditionals', () => { expect(copyrightInformationShouldBeRequested({[questionNames.LICENSE]: undefined})).toBe(false); }); }); - - describe('choices by project visibility', () => { - const publicChoices = any.objectWithKeys( - any.listOf(any.word), - {factory: () => ({...any.simpleObject(), public: true})} - ); - const privateChoices = any.objectWithKeys( - any.listOf(any.word), - {factory: () => ({...any.simpleObject(), private: true})} - ); - const choices = {...publicChoices, ...privateChoices}; - - it('should list the public hosts for `Public` projects', () => { - expect(filterChoicesByVisibility(choices, 'Public')).toEqual([ - ...Object.keys(publicChoices), - new Separator(), - 'Other' - ]); - }); - - it('should list the private hosts for `Private` projects', () => { - expect(filterChoicesByVisibility(choices, 'Private')).toEqual([ - ...Object.keys(privateChoices), - new Separator(), - 'Other' - ]); - }); - }); }); diff --git a/src/prompts/terminal-prompt.js b/src/prompts/terminal-prompt.js new file mode 100644 index 00000000..2f0ed522 --- /dev/null +++ b/src/prompts/terminal-prompt.js @@ -0,0 +1,5 @@ +import {prompt} from '@form8ion/overridable-prompts'; + +export default function (decisions) { + return ({questions}) => prompt(questions, decisions); +} diff --git a/src/prompts/terminal-prompt.test.js b/src/prompts/terminal-prompt.test.js new file mode 100644 index 00000000..0ac960dc --- /dev/null +++ b/src/prompts/terminal-prompt.test.js @@ -0,0 +1,20 @@ +import {prompt as promptWithInquirer} from '@form8ion/overridable-prompts'; + +import {when} from 'jest-when'; +import {describe, it, vi, expect} from 'vitest'; +import any from '@travi/any'; + +import prompt from './terminal-prompt.js'; + +vi.mock('@form8ion/overridable-prompts'); + +describe('terminal prompt', () => { + it('should present the provided questions using inquirer', async () => { + const questions = any.listOf(any.simpleObject); + const decisions = any.simpleObject(); + const answers = any.simpleObject(); + when(promptWithInquirer).calledWith(questions, decisions).mockResolvedValue(answers); + + expect(await prompt(decisions)({questions})).toEqual(answers); + }); +}); diff --git a/src/scaffolder.js b/src/scaffolder.js index 3d4a89ef..342d3a40 100644 --- a/src/scaffolder.js +++ b/src/scaffolder.js @@ -5,10 +5,9 @@ import {reportResults} from '@form8ion/results-reporter'; import {scaffold as scaffoldReadme} from '@form8ion/readme'; import {info} from '@travi/cli-messages'; -import {scaffold as scaffoldLanguage, prompt as promptForLanguageDetails} from './language/index.js'; -import {initialize as scaffoldGit, scaffold as liftGit} from './vcs/git/git.js'; +import {scaffold as scaffoldLanguage} from './language/index.js'; +import {scaffold as scaffoldGit} from './vcs/git/git.js'; import {scaffold as scaffoldLicense} from './license/index.js'; -import {scaffold as scaffoldVcsHost} from './vcs/host/index.js'; import scaffoldDependencyUpdater from './dependency-updater/scaffolder.js'; import {promptForBaseDetails} from './prompts/questions.js'; import {validate} from './options-validator.js'; @@ -19,7 +18,7 @@ import lift from './lift.js'; export async function scaffold(options) { const projectRoot = process.cwd(); - const {languages = {}, vcsHosts = {}, decisions, dependencyUpdaters} = validate(options); + const {decisions, plugins: {dependencyUpdaters, languages, vcsHosts = {}}} = validate(options); const { [coreQuestionNames.PROJECT_NAME]: projectName, @@ -32,50 +31,41 @@ export async function scaffold(options) { } = await promptForBaseDetails(projectRoot, decisions); const copyright = {year: copyrightYear, holder: copyHolder}; - const [vcs, contributing, license] = await Promise.all([ - scaffoldGit(gitRepo, projectRoot, projectName, vcsHosts, visibility, decisions), + const [vcsResults, contributing, license] = await Promise.all([ + scaffoldGit(gitRepo, projectRoot, projectName, description, vcsHosts, visibility, decisions), scaffoldContributing({visibility}), scaffoldLicense({projectRoot, license: chosenLicense, copyright}), scaffoldReadme({projectName, projectRoot, description}), scaffoldEditorConfig({projectRoot}) ]); - const {[questionNames.PROJECT_LANGUAGE]: projectLanguage} = await promptForLanguageDetails(languages, decisions); + const dependencyUpdaterResults = vcsResults.vcs && await scaffoldDependencyUpdater( + dependencyUpdaters, + decisions, + {projectRoot, vcs: vcsResults.vcs} + ); const language = await scaffoldLanguage( languages, - projectLanguage, - {projectRoot, projectName, vcs, visibility, license: chosenLicense || 'UNLICENSED', description} - ); - - const dependencyUpdaterResults = vcs && await scaffoldDependencyUpdater( - dependencyUpdaters, decisions, - {projectRoot, vcs} + {projectRoot, projectName, vcs: vcsResults.vcs, visibility, license: chosenLicense || 'UNLICENSED', description} ); - const contributors = [license, language, dependencyUpdaterResults, contributing].filter(Boolean); - const contributedTasks = contributors - .map(contributor => contributor.nextSteps) - .filter(Boolean) - .reduce((acc, contributedNextSteps) => ([...acc, ...contributedNextSteps]), []); + const mergedResults = deepmerge.all([ + license, + language, + dependencyUpdaterResults, + contributing, + vcsResults + ].filter(Boolean)); - const vcsHostResults = vcs && await scaffoldVcsHost(vcsHosts, { - ...vcs, + await lift({ projectRoot, - description, - visibility, - ...language && { - homepage: language.projectDetails && language.projectDetails.homepage, - tags: language.tags - }, - nextSteps: contributedTasks + vcs: vcsResults.vcs, + results: mergedResults, + enhancers: {...dependencyUpdaters, ...vcsHosts} }); - await lift({projectRoot, results: deepmerge.all(contributors)}); - - const gitResults = gitRepo && await liftGit({projectRoot, origin: vcsHostResults}); - if (language && language.verificationCommand) { info('Verifying the generated project'); @@ -84,10 +74,5 @@ export async function scaffold(options) { await subprocess; } - reportResults({ - nextSteps: [ - ...(gitResults && gitResults.nextSteps) ? gitResults.nextSteps : [], - ...contributedTasks - ] - }); + reportResults(mergedResults); } diff --git a/src/scaffolder.test.js b/src/scaffolder.test.js index 0be7634e..ddc36bc8 100644 --- a/src/scaffolder.test.js +++ b/src/scaffolder.test.js @@ -8,11 +8,9 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import any from '@travi/any'; import {when} from 'jest-when'; -import {scaffold as liftGit, initialize as scaffoldGit} from './vcs/git/git.js'; -import * as vcsHostScaffolder from './vcs/host/scaffolder.js'; +import {scaffold as scaffoldGit} from './vcs/git/git.js'; import * as licenseScaffolder from './license/scaffolder.js'; -import * as languageScaffolder from './language/scaffolder.js'; -import * as languagePrompt from './language/prompt.js'; +import scaffoldLanguage from './language/scaffolder.js'; import * as dependencyUpdaterScaffolder from './dependency-updater/scaffolder.js'; import * as optionsValidator from './options-validator.js'; import * as prompts from './prompts/questions.js'; @@ -27,10 +25,8 @@ vi.mock('@form8ion/execa-wrapper'); vi.mock('@form8ion/results-reporter'); vi.mock('./readme'); vi.mock('./vcs/git/git.js'); -vi.mock('./vcs/host/scaffolder'); vi.mock('./license/scaffolder'); vi.mock('./language/scaffolder'); -vi.mock('./language/prompt'); vi.mock('./dependency-updater/scaffolder'); vi.mock('./options-validator'); vi.mock('./prompts/questions'); @@ -46,13 +42,13 @@ describe('project scaffolder', () => { const description = any.string(); const homepage = any.url(); const license = any.string(); - const projectLanguage = any.word(); const licenseBadge = any.url(); - const languageScaffolders = any.simpleObject(); + const languages = any.simpleObject(); const vcsHosts = any.simpleObject(); const documentation = any.simpleObject(); const vcs = any.simpleObject(); - const vcsOriginDetails = any.simpleObject(); + const gitNextSteps = any.listOf(any.simpleObject); + const vcsResults = {...any.simpleObject(), vcs, nextSteps: gitNextSteps}; const tags = any.listOf(any.word); const visibility = any.word(); const vcsIgnore = any.simpleObject(); @@ -76,7 +72,6 @@ describe('project scaffolder', () => { const copyright = {year, holder}; const gitRepoShouldBeInitialized = true; const dependencyUpdaters = any.simpleObject(); - const gitNextSteps = any.listOf(any.simpleObject); const dependencyUpdaterNextSteps = any.listOf(any.simpleObject); const dependencyUpdaterContributionBadges = any.simpleObject(); const dependencyUpdaterResults = { @@ -92,9 +87,16 @@ describe('project scaffolder', () => { }; const licenseResults = {badges: {consumer: {license: licenseBadge}}}; const contributingResults = any.simpleObject(); + const mergedResults = deepmerge.all([ + licenseResults, + languageResults, + dependencyUpdaterResults, + contributingResults, + vcsResults + ]); when(optionsValidator.validate) .calledWith(options) - .mockReturnValue({languages: languageScaffolders, vcsHosts, decisions, dependencyUpdaters}); + .mockReturnValue({decisions, plugins: {dependencyUpdaters, languages, vcsHosts}}); when(prompts.promptForBaseDetails) .calledWith(projectPath, decisions) .mockResolvedValue({ @@ -106,31 +108,13 @@ describe('project scaffolder', () => { [coreQuestionNames.COPYRIGHT_YEAR]: year, [coreQuestionNames.VISIBILITY]: visibility }); - when(languagePrompt.default) - .calledWith(languageScaffolders, decisions) - .mockResolvedValue({[questionNames.PROJECT_LANGUAGE]: projectLanguage}); when(scaffoldGit) - .calledWith(gitRepoShouldBeInitialized, projectPath, projectName, vcsHosts, visibility, decisions) - .mockResolvedValue(vcs); - liftGit.mockResolvedValue({nextSteps: gitNextSteps}); + .calledWith(gitRepoShouldBeInitialized, projectPath, projectName, description, vcsHosts, visibility, decisions) + .mockResolvedValue(vcsResults); when(licenseScaffolder.default) .calledWith({projectRoot: projectPath, license, copyright}) .mockResolvedValue(licenseResults); - when(vcsHostScaffolder.default) - .calledWith( - vcsHosts, - { - ...vcs, - projectRoot: projectPath, - description, - visibility, - homepage: undefined, - nextSteps: [...dependencyUpdaterNextSteps], - tags - } - ) - .mockResolvedValue(vcsOriginDetails); - languageScaffolder.default.mockResolvedValue(languageResults); + scaffoldLanguage.mockResolvedValue(languageResults); when(dependencyUpdaterScaffolder.default) .calledWith(dependencyUpdaters, decisions, {projectRoot: projectPath, vcs}) .mockResolvedValue(dependencyUpdaterResults); @@ -138,10 +122,6 @@ describe('project scaffolder', () => { await scaffold(options); - expect(liftGit).toHaveBeenCalledWith({ - projectRoot: projectPath, - origin: vcsOriginDetails - }); expect(scaffoldReadme).toHaveBeenCalledWith({projectName, projectRoot: projectPath, description}); expect(dependencyUpdaterScaffolder.default).toHaveBeenCalledWith( dependencyUpdaters, @@ -151,35 +131,34 @@ describe('project scaffolder', () => { expect(scaffoldEditorconfig).toHaveBeenCalledWith({projectRoot: projectPath}); expect(lift).toHaveBeenCalledWith({ projectRoot: projectPath, - results: deepmerge.all([licenseResults, languageResults, dependencyUpdaterResults, contributingResults]) - }); - expect(resultsReporter.reportResults).toHaveBeenCalledWith({ - nextSteps: [...gitNextSteps, ...dependencyUpdaterNextSteps] + vcs, + results: mergedResults, + enhancers: {...dependencyUpdaters, ...vcsHosts} }); + expect(resultsReporter.reportResults).toHaveBeenCalledWith(mergedResults); }); it('should consider all options to be optional', async () => { const gitRepoShouldBeInitialized = any.boolean(); - optionsValidator.validate.mockReturnValue({}); + optionsValidator.validate.mockReturnValue({plugins: {}}); when(prompts.promptForBaseDetails) .calledWith(projectPath, undefined) .mockResolvedValue({ [coreQuestionNames.PROJECT_NAME]: projectName, [questionNames.GIT_REPO]: gitRepoShouldBeInitialized }); - languagePrompt.default.mockResolvedValue({}); + when(scaffoldGit).mockResolvedValue(vcsResults); await scaffold(); expect(scaffoldGit) - .toHaveBeenCalledWith(gitRepoShouldBeInitialized, projectPath, projectName, {}, undefined, undefined); + .toHaveBeenCalledWith(gitRepoShouldBeInitialized, projectPath, projectName, undefined, {}, undefined, undefined); }); - it('should consider each option optional', async () => { + it('should consider each option except the plugins map optional', async () => { const emptyOptions = {}; - when(optionsValidator.validate).calledWith(emptyOptions).mockReturnValue({}); - when(prompts.promptForBaseDetails).calledWith(projectPath, undefined, undefined).mockResolvedValue({}); - languagePrompt.default.mockResolvedValue({}); + when(optionsValidator.validate).calledWith(emptyOptions).mockReturnValue({plugins: {}}); + when(prompts.promptForBaseDetails).calledWith(projectPath, undefined).mockResolvedValue({}); scaffoldGit.mockResolvedValue({}); await scaffold(emptyOptions); @@ -207,37 +186,38 @@ describe('project scaffolder', () => { contribution: any.simpleObject() }; const languageResults = {badges: languageBadges, vcsIgnore, documentation}; + when(optionsValidator.validate).calledWith(options).mockReturnValue({plugins: {vcsHosts}}); when(prompts.promptForBaseDetails) - .calledWith(projectPath, undefined, undefined) - .mockResolvedValue({[coreQuestionNames.VISIBILITY]: visibility}); + .calledWith(projectPath, undefined) + .mockResolvedValue({ + [coreQuestionNames.DESCRIPTION]: description, + [questionNames.GIT_REPO]: true, + [coreQuestionNames.PROJECT_NAME]: projectName, + [coreQuestionNames.VISIBILITY]: visibility + }); when(scaffoldContributing).calledWith({visibility}).mockReturnValue({badges: contributingBadges}); - languageScaffolder.default.mockResolvedValue(languageResults); - vcsHostScaffolder.default.mockResolvedValue(vcsOriginDetails); + scaffoldLanguage.mockResolvedValue(languageResults); dependencyUpdaterScaffolder.default.mockResolvedValue({badges: dependencyUpdaterBadges}); licenseScaffolder.default.mockResolvedValue({badges: licenseBadges}); + when(scaffoldGit).mockResolvedValue(vcsResults); await scaffold(options); - expect(liftGit).toHaveBeenCalledWith({projectRoot: projectPath, origin: vcsOriginDetails}); expect(scaffoldReadme).toHaveBeenCalledWith({projectName, projectRoot: projectPath, description}); }); it('should not scaffold the git repo if not requested', async () => { - when(optionsValidator.validate).calledWith(options).mockReturnValue({}); + when(optionsValidator.validate).calledWith(options).mockReturnValue({plugins: {}}); prompts.promptForBaseDetails.mockResolvedValue({[questionNames.GIT_REPO]: false}); - languagePrompt.default.mockResolvedValue({}); scaffoldReadme.mockResolvedValue(); - scaffoldGit.mockResolvedValue(undefined); + scaffoldGit.mockResolvedValue({}); await scaffold(options); - expect(liftGit).not.toHaveBeenCalled(); - expect(vcsHostScaffolder.default).not.toHaveBeenCalled(); expect(dependencyUpdaterScaffolder.default).not.toHaveBeenCalled(); }); it('should scaffold the details of the chosen language plugin', async () => { - const gitNextSteps = any.listOf(any.simpleObject); const languageConsumerBadges = any.simpleObject(); const languageContributionBadges = any.simpleObject(); const languageStatusBadges = any.simpleObject(); @@ -259,9 +239,8 @@ describe('project scaffolder', () => { }; when(optionsValidator.validate) .calledWith(options) - .mockReturnValue({languages: languageScaffolders, vcsHosts, decisions}); - scaffoldGit.mockResolvedValue(vcs); - liftGit.mockResolvedValue({nextSteps: gitNextSteps}); + .mockReturnValue({decisions, plugins: {languages, vcsHosts}}); + scaffoldGit.mockResolvedValue(vcsResults); prompts.promptForBaseDetails.mockResolvedValue({ [coreQuestionNames.PROJECT_NAME]: projectName, [coreQuestionNames.VISIBILITY]: visibility, @@ -269,10 +248,7 @@ describe('project scaffolder', () => { [coreQuestionNames.LICENSE]: license, [coreQuestionNames.DESCRIPTION]: description }); - when(languagePrompt.default) - .calledWith(languageScaffolders, decisions) - .mockResolvedValue({[questionNames.PROJECT_LANGUAGE]: projectLanguage}); - when(languageScaffolder.default).calledWith(languageScaffolders, projectLanguage, { + when(scaffoldLanguage).calledWith(languages, decisions, { projectName, projectRoot: projectPath, visibility, @@ -280,18 +256,6 @@ describe('project scaffolder', () => { vcs, description }).mockResolvedValue(languageResults); - when(vcsHostScaffolder.default).calledWith( - vcsHosts, - { - ...vcs, - projectRoot: projectPath, - description, - homepage, - visibility, - nextSteps: languageNextSteps, - tags - } - ).mockResolvedValue(vcsOriginDetails); when(execa).calledWith(verificationCommand, {shell: true}).mockReturnValue({stdout: {pipe: execaPipe}}); dependencyUpdaterScaffolder.default.mockResolvedValue({}); licenseScaffolder.default.mockResolvedValue({}); @@ -299,17 +263,16 @@ describe('project scaffolder', () => { await scaffold(options); - expect(liftGit).toHaveBeenCalledWith({projectRoot: projectPath, origin: vcsOriginDetails}); expect(scaffoldReadme).toHaveBeenCalledWith({projectName, projectRoot: projectPath, description}); expect(execaPipe).toHaveBeenCalledWith(process.stdout); - expect(resultsReporter.reportResults).toHaveBeenCalledWith({nextSteps: [...gitNextSteps, ...languageNextSteps]}); + expect(resultsReporter.reportResults).toHaveBeenCalledWith(deepmerge.all([languageResults, vcsResults])); }); it('should consider the language details to be optional', async () => { when(optionsValidator.validate) .calledWith(options) - .mockReturnValue({languages: languageScaffolders, vcsHosts, decisions}); - scaffoldGit.mockResolvedValue(vcs); + .mockReturnValue({vcsHosts, decisions, plugins: {languages}}); + scaffoldGit.mockResolvedValue(vcsResults); prompts.promptForBaseDetails.mockResolvedValue({ [coreQuestionNames.PROJECT_NAME]: projectName, [coreQuestionNames.VISIBILITY]: visibility, @@ -317,50 +280,43 @@ describe('project scaffolder', () => { [coreQuestionNames.LICENSE]: license, [coreQuestionNames.DESCRIPTION]: description }); - when(languagePrompt.default) - .calledWith(languageScaffolders, decisions) - .mockResolvedValue({[questionNames.PROJECT_LANGUAGE]: projectLanguage}); - vcsHostScaffolder.default.mockResolvedValue(vcsOriginDetails); - languageScaffolder.default.mockResolvedValue({}); + scaffoldLanguage.mockResolvedValue({}); dependencyUpdaterScaffolder.default.mockResolvedValue({}); licenseScaffolder.default.mockResolvedValue({}); scaffoldContributing.mockResolvedValue({}); await scaffold(options); - expect(liftGit).toHaveBeenCalledWith({projectRoot: projectPath, origin: vcsOriginDetails}); expect(scaffoldReadme).toHaveBeenCalledWith({projectName, projectRoot: projectPath, description}); expect(execa).not.toHaveBeenCalled(); }); it('should pass the license to the language scaffolder as `UNLICENSED` when no license was chosen', async () => { - when(optionsValidator.validate).calledWith(options).mockReturnValue({}); + when(optionsValidator.validate).calledWith(options).mockReturnValue({plugins: {languages}, decisions}); prompts.promptForBaseDetails.mockResolvedValue({}); - languagePrompt.default.mockResolvedValue({[questionNames.PROJECT_LANGUAGE]: projectLanguage}); - scaffoldGit.mockResolvedValue({}); + scaffoldGit.mockResolvedValue(vcsResults); await scaffold(options); - expect(languageScaffolder.default).toHaveBeenCalledWith( - {}, - projectLanguage, + expect(scaffoldLanguage).toHaveBeenCalledWith( + languages, + decisions, { license: 'UNLICENSED', description: undefined, projectName: undefined, projectRoot: projectPath, - vcs: {}, + vcs, visibility: undefined } ); }); it('should not run a verification command when one is not provided', async () => { - when(optionsValidator.validate).calledWith(options).mockReturnValue({}); + when(optionsValidator.validate).calledWith(options).mockReturnValue({plugins: {}}); prompts.promptForBaseDetails.mockResolvedValue({}); - languagePrompt.default.mockResolvedValue({}); scaffoldGit.mockResolvedValue({}); - languageScaffolder.default.mockResolvedValue({badges: {}, projectDetails: {}}); + scaffoldLanguage.mockResolvedValue({badges: {}, projectDetails: {}}); await scaffold(options); diff --git a/src/vcs/git/git.js b/src/vcs/git/git.js index bede3829..eff480c9 100644 --- a/src/vcs/git/git.js +++ b/src/vcs/git/git.js @@ -3,8 +3,7 @@ import hostedGitInfo from 'hosted-git-info'; import {info, warn} from '@travi/cli-messages'; import {scaffold as scaffoldGit} from '@form8ion/git'; -import promptForVcsHostDetails from '../host/prompt.js'; -import {questionNames} from '../../prompts/question-names.js'; +import {scaffold as scaffoldVcsHost} from '../host/index.js'; async function getExistingRemotes(git) { try { @@ -18,7 +17,7 @@ async function getExistingRemotes(git) { } } -async function defineRemoteOrigin(projectRoot, origin) { +async function defineRemoteOrigin(projectRoot, sshUrl) { const git = simpleGit({baseDir: projectRoot}); const existingRemotes = await getExistingRemotes(git); @@ -28,10 +27,10 @@ async function defineRemoteOrigin(projectRoot, origin) { return {nextSteps: []}; } - if (origin.sshUrl) { - info(`Setting remote origin to ${origin.sshUrl}`, {level: 'secondary'}); + if (sshUrl) { + info(`Setting remote origin to ${sshUrl}`, {level: 'secondary'}); - await git.addRemote('origin', origin.sshUrl); + await git.addRemote('origin', sshUrl); // info('Setting the local `master` branch to track `origin/master`'); // @@ -48,10 +47,11 @@ async function defineRemoteOrigin(projectRoot, origin) { return {nextSteps: []}; } -export async function initialize( +export async function scaffold( gitRepoShouldBeInitialized, projectRoot, projectName, + description, vcsHosts, visibility, decisions @@ -64,28 +64,21 @@ export async function initialize( const remoteOrigin = await git.remote(['get-url', 'origin']); const {user, project, type} = hostedGitInfo.fromUrl(remoteOrigin); - return {owner: user, name: project, host: type}; + return {vcs: {owner: user, name: project, host: type}}; } - const [answers] = await Promise.all([ - promptForVcsHostDetails(vcsHosts, visibility, decisions), + const [{vcs: {host, owner, name, sshUrl}}] = await Promise.all([ + scaffoldVcsHost(vcsHosts, visibility, decisions, {projectName, projectRoot, description, visibility}), scaffoldGit({projectRoot}) ]); + const remoteOriginResults = await defineRemoteOrigin(projectRoot, sshUrl); + return { - host: answers[questionNames.REPO_HOST].toLowerCase(), - owner: answers[questionNames.REPO_OWNER], - name: projectName + vcs: {host, owner, name}, + nextSteps: [{summary: 'Commit scaffolded files'}, ...remoteOriginResults.nextSteps] }; } - return undefined; -} - -export async function scaffold({projectRoot, origin}) { - info('Finishing Git Configuration'); - - const remoteOriginResults = await defineRemoteOrigin(projectRoot, origin); - - return {nextSteps: [{summary: 'Commit scaffolded files'}, ...remoteOriginResults.nextSteps]}; + return {}; } diff --git a/src/vcs/git/git.test.js b/src/vcs/git/git.test.js index d941ede6..9b262614 100644 --- a/src/vcs/git/git.test.js +++ b/src/vcs/git/git.test.js @@ -6,15 +6,14 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import any from '@travi/any'; import {when} from 'jest-when'; -import promptForVcsHostDetails from '../host/prompt.js'; -import {questionNames} from '../../prompts/question-names.js'; -import {initialize, scaffold} from './git.js'; +import {scaffold as scaffoldVcsHost} from '../host/index.js'; +import {scaffold} from './git.js'; vi.mock('node:fs'); vi.mock('hosted-git-info'); vi.mock('simple-git'); vi.mock('@form8ion/git'); -vi.mock('../host/prompt'); +vi.mock('../host/index.js'); vi.mock('./ignore/index.js'); describe('git', () => { @@ -22,6 +21,15 @@ describe('git', () => { const projectRoot = any.string(); const visibility = any.word(); const decisions = any.simpleObject(); + const vcsHost = `F${any.word()})O${any.word()}O`; + const vcsHostAccount = any.word(); + const repositoryName = any.word(); + const githubAccount = any.word(); + const projectName = any.word(); + const description = any.sentence(); + const sshUrl = any.url(); + const vcsDetails = {name: repositoryName, owner: vcsHostAccount, host: vcsHost, sshUrl}; + const vcsHostResults = {...any.simpleObject(), vcs: vcsDetails}; beforeEach(() => { checkIsRepo = vi.fn(); @@ -38,96 +46,74 @@ describe('git', () => { vi.clearAllMocks(); }); - describe('initialization', () => { - const repoHost = `F${any.word()})O${any.word()}O`; - const repoOwner = any.word(); - const githubAccount = any.word(); - const projectName = any.word(); - - it('should initialize the git repo', async () => { - const vcsHosts = any.simpleObject(); - when(checkIsRepo).calledWith('root').mockResolvedValue(false); - when(promptForVcsHostDetails) - .calledWith(vcsHosts, visibility, decisions) - .mockResolvedValue({[questionNames.REPO_HOST]: repoHost, [questionNames.REPO_OWNER]: repoOwner}); - - const hostDetails = await initialize(true, projectRoot, projectName, vcsHosts, visibility, decisions); - - expect(scaffoldGit).toHaveBeenCalledWith({projectRoot}); - expect(hostDetails).toEqual({host: repoHost.toLowerCase(), owner: repoOwner, name: projectName}); - }); - - it('should not initialize the git repo if the project will not be versioned', async () => { - when(checkIsRepo).calledWith('root').mockResolvedValue(false); - when(promptForVcsHostDetails) - .calledWith(githubAccount, visibility, decisions) - .mockResolvedValue({[questionNames.REPO_HOST]: repoHost, [questionNames.REPO_OWNER]: repoOwner}); - - const hostDetails = await initialize(false, projectRoot, projectName, githubAccount, visibility, decisions); - - expect(scaffoldGit).not.toHaveBeenCalled(); - expect(hostDetails).toBe(undefined); - }); - - it('should return the git details from an existing account', async () => { - const repoName = any.word(); - const remoteOrigin = any.url(); - when(checkIsRepo).calledWith('root').mockResolvedValue(true); - when(remote).calledWith(['get-url', 'origin']).mockResolvedValue(remoteOrigin); - when(hostedGitInfo.fromUrl) - .calledWith(remoteOrigin) - .mockReturnValue({user: repoOwner, project: repoName, type: repoHost.toLowerCase()}); - - const hostDetails = await initialize(true, projectRoot, projectName, githubAccount, visibility); - - expect(scaffoldGit).not.toHaveBeenCalled(); - expect(hostDetails).toEqual({host: repoHost.toLowerCase(), owner: repoOwner, name: repoName}); + it('should initialize the git repo', async () => { + const vcsHosts = any.simpleObject(); + when(checkIsRepo).calledWith('root').mockResolvedValue(false); + when(scaffoldVcsHost) + .calledWith(vcsHosts, visibility, decisions, {projectName, projectRoot, description, visibility}) + .mockResolvedValue(vcsHostResults); + listRemote.mockResolvedValue(any.listOf(any.word)); + + expect(await scaffold( + true, + projectRoot, + projectName, + description, + vcsHosts, + visibility, + decisions + )).toEqual({ + vcs: {owner: vcsHostAccount, name: repositoryName, host: vcsHost}, + nextSteps: [ + {summary: 'Commit scaffolded files'}, + {summary: 'Set local `master` branch to track upstream `origin/master`'} + ] }); + expect(scaffoldGit).toHaveBeenCalledWith({projectRoot}); + expect(addRemote).toHaveBeenCalledWith('origin', sshUrl); }); - describe('scaffold', () => { - const results = any.simpleObject(); - - it('should scaffold the git repo', async () => { - listRemote.mockRejectedValue(new Error('fatal: No remote configured to list refs from.\n')); + it('should not initialize the git repo if the project will not be versioned', async () => { + when(checkIsRepo).calledWith('root').mockResolvedValue(false); - const result = await scaffold({projectRoot, origin: {}, results}); + const hostDetails = await scaffold(false, projectRoot, projectName, githubAccount, visibility, decisions); - expect(result.nextSteps).toEqual([{summary: 'Commit scaffolded files'}]); - }); + expect(scaffoldGit).not.toHaveBeenCalled(); + expect(hostDetails).toEqual({}); + }); - it('throws git errors that are not a lack of defined remotes', async () => { - const error = new Error(any.sentence()); - listRemote.mockRejectedValue(error); + it('should return the git details from an existing remote', async () => { + const repoName = any.word(); + const remoteOrigin = any.url(); + when(checkIsRepo).calledWith('root').mockResolvedValue(true); + when(remote).calledWith(['get-url', 'origin']).mockResolvedValue(remoteOrigin); + when(hostedGitInfo.fromUrl) + .calledWith(remoteOrigin) + .mockReturnValue({user: vcsHostAccount, project: repoName, type: vcsHost.toLowerCase()}); - await expect(scaffold({projectRoot, origin: {}})).rejects.toThrow(error); - }); + const hostDetails = await scaffold(true, projectRoot, projectName, githubAccount, visibility); - it('should define the remote origin when an ssl-url is provided for the remote', async () => { - const sshUrl = any.url(); - // const branch = any.simpleObject(); - // gitBranch.lookup.withArgs(repository, 'master', gitBranch.BRANCH.LOCAL).resolves(branch); - listRemote.mockResolvedValue(any.listOf(any.word)); + expect(scaffoldGit).not.toHaveBeenCalled(); + expect(hostDetails).toEqual({vcs: {host: vcsHost.toLowerCase(), owner: vcsHostAccount, name: repoName}}); + }); - const result = await scaffold({projectRoot, origin: {sshUrl}}); + it('should throw git errors that are not a lack of defined remotes', async () => { + const error = new Error(any.sentence()); + when(checkIsRepo).calledWith('root').mockResolvedValue(false); + when(scaffoldVcsHost).mockResolvedValue(vcsHostResults); + listRemote.mockRejectedValue(error); - expect(addRemote).toHaveBeenCalledWith('origin', sshUrl); - expect(result.nextSteps).toEqual([ - {summary: 'Commit scaffolded files'}, - {summary: 'Set local `master` branch to track upstream `origin/master`'} - ]); - // assert.calledWith(gitBranch.setUpstream, branch, 'origin/master'); - }); + await expect(scaffold(true, projectRoot)).rejects.toThrow(error); + }); - it('should not define the remote origin if it already exists', async () => { - const sshUrl = any.url(); - listRemote.mockResolvedValue(['origin']); + it('should not define the remote origin if it already exists', async () => { + when(checkIsRepo).calledWith('root').mockResolvedValue(false); + when(scaffoldVcsHost).mockResolvedValue(vcsHostResults); + listRemote.mockResolvedValue(['origin']); - await scaffold({projectRoot, origin: {sshUrl}}); + const result = await scaffold(true, projectRoot); - expect(addRemote).not.toHaveBeenCalled(); - // assert.notCalled(gitBranch.lookup); - // assert.notCalled(gitBranch.setUpstream); - }); + expect(addRemote).not.toHaveBeenCalled(); + expect(result.nextSteps).toEqual([{summary: 'Commit scaffolded files'}]); }); }); diff --git a/src/vcs/host/prompt.js b/src/vcs/host/prompt.js index 545c7527..ae9c5495 100644 --- a/src/vcs/host/prompt.js +++ b/src/vcs/host/prompt.js @@ -1,16 +1,15 @@ import {prompt} from '@form8ion/overridable-prompts'; import {questionNames} from '../../prompts/question-names.js'; -import {filterChoicesByVisibility} from '../../prompts/conditionals.js'; export default async function (hosts, visibility, decisions) { const answers = await prompt([{ name: questionNames.REPO_HOST, type: 'list', message: 'Where will the repository be hosted?', - choices: filterChoicesByVisibility(hosts, visibility) + choices: hosts }], decisions); const host = hosts[answers[questionNames.REPO_HOST]]; - return {...answers, ...host && await host.prompt({decisions})}; + return {...answers, ...host}; } diff --git a/src/vcs/host/prompt.test.js b/src/vcs/host/prompt.test.js index 800fa8ae..3c7eed67 100644 --- a/src/vcs/host/prompt.test.js +++ b/src/vcs/host/prompt.test.js @@ -4,7 +4,6 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import any from '@travi/any'; import {when} from 'jest-when'; -import * as conditionals from '../../prompts/conditionals.js'; import {questionNames} from '../../prompts/question-names.js'; import promptForVcsHostDetails from './prompt.js'; @@ -12,7 +11,6 @@ vi.mock('@form8ion/overridable-prompts'); vi.mock('../../prompts/conditionals'); describe('vcs host details prompt', () => { - const filteredHostChoices = any.listOf(any.word); const answers = any.simpleObject(); const decisions = any.simpleObject(); @@ -23,35 +21,27 @@ describe('vcs host details prompt', () => { it('should prompt for the vcs hosting details', async () => { const host = any.string(); const hostNames = [...any.listOf(any.string), host]; - const hostPrompt = vi.fn(); - const hosts = any.objectWithKeys( - hostNames, - {factory: key => ({prompt: host === key ? hostPrompt : () => undefined})} - ); + const hosts = any.objectWithKeys(hostNames, {factory: () => ({})}); const answersWithHostChoice = {...answers, [questionNames.REPO_HOST]: host}; - const hostAnswers = any.simpleObject(); - when(hostPrompt).calledWith({decisions}).mockResolvedValue(hostAnswers); - when(conditionals.filterChoicesByVisibility).calledWith(hosts, null).mockReturnValue(filteredHostChoices); when(prompts.prompt).calledWith([{ name: questionNames.REPO_HOST, type: 'list', message: 'Where will the repository be hosted?', - choices: filteredHostChoices + choices: hosts }], decisions).mockResolvedValue(answersWithHostChoice); - expect(await promptForVcsHostDetails(hosts, null, decisions)).toEqual({...answersWithHostChoice, ...hostAnswers}); + expect(await promptForVcsHostDetails(hosts, null, decisions)).toEqual(answersWithHostChoice); }); it('should not throw an error when `Other` is chosen as the host', async () => { const hosts = {}; const visibility = any.word(); const answersWithHostChoice = {...answers, [questionNames.REPO_HOST]: 'Other'}; - when(conditionals.filterChoicesByVisibility).calledWith(hosts, visibility).mockReturnValue(filteredHostChoices); when(prompts.prompt).calledWith([{ name: questionNames.REPO_HOST, type: 'list', message: 'Where will the repository be hosted?', - choices: filteredHostChoices + choices: hosts }], decisions).mockResolvedValue(answersWithHostChoice); expect(await promptForVcsHostDetails(hosts, visibility, decisions)).toEqual(answersWithHostChoice); diff --git a/src/vcs/host/scaffolder.js b/src/vcs/host/scaffolder.js index d32d58cb..56b35e80 100644 --- a/src/vcs/host/scaffolder.js +++ b/src/vcs/host/scaffolder.js @@ -1,11 +1,16 @@ -export default function (hosts, options) { +import {questionNames} from '../../prompts/question-names.js'; +import terminalPromptFactory from '../../prompts/terminal-prompt.js'; +import promptForVcsHostDetails from './prompt.js'; + +export default async function (hosts, visibility, decisions, options) { + const {[questionNames.REPO_HOST]: chosenHost} = await promptForVcsHostDetails(hosts, visibility, decisions); + const lowercasedHosts = Object.fromEntries( Object.entries(hosts).map(([name, details]) => [name.toLowerCase(), details]) ); - const {host: chosenHost, ...rest} = options; - const host = lowercasedHosts[chosenHost]; + const host = lowercasedHosts[chosenHost.toLowerCase()]; - if (host) return host.scaffolder(rest); + if (host) return host.scaffold(options, {prompt: terminalPromptFactory(decisions)}); - return {}; + return {vcs: {}}; } diff --git a/src/vcs/host/scaffolder.test.js b/src/vcs/host/scaffolder.test.js index 5343ff1d..7fd3d8fe 100644 --- a/src/vcs/host/scaffolder.test.js +++ b/src/vcs/host/scaffolder.test.js @@ -2,17 +2,41 @@ import {describe, expect, it, vi} from 'vitest'; import any from '@travi/any'; import {when} from 'jest-when'; +import {questionNames} from '../../prompts/question-names.js'; +import terminalPromptFactory from '../../prompts/terminal-prompt.js'; +import promptForVcsHostDetails from './prompt.js'; import scaffoldVcsHost from './scaffolder.js'; +vi.mock('../../prompts/terminal-prompt.js'); +vi.mock('./prompt'); + describe('vcs host scaffolder', () => { + const options = any.simpleObject(); + const visibility = any.word(); + const decisions = any.simpleObject(); + it('should scaffold the chosen vcs host', async () => { const chosenHost = `${any.word()}CAPITAL${any.word()}`; - const otherOptions = any.simpleObject(); const results = any.simpleObject(); const chosenHostScaffolder = vi.fn(); - const hostScaffolders = {...any.simpleObject(), [chosenHost]: {scaffolder: chosenHostScaffolder}}; - when(chosenHostScaffolder).calledWith(otherOptions).mockResolvedValue(results); + const hostPlugins = {...any.simpleObject(), [chosenHost.toLowerCase()]: {scaffold: chosenHostScaffolder}}; + const owner = any.word; + const terminalPrompt = () => undefined; + when(terminalPromptFactory).calledWith(decisions).mockReturnValue(terminalPrompt); + when(promptForVcsHostDetails) + .calledWith(hostPlugins, visibility, decisions) + .mockResolvedValue({[questionNames.REPO_HOST]: chosenHost, [questionNames.REPO_OWNER]: owner}); + when(chosenHostScaffolder).calledWith(options, {prompt: terminalPrompt}).mockResolvedValue(results); + + expect(await scaffoldVcsHost(hostPlugins, visibility, decisions, options)).toEqual(results); + }); + + it('should return empty `vcs` results when no matching host is available', async () => { + const hostPlugins = any.simpleObject(); + when(promptForVcsHostDetails) + .calledWith(hostPlugins, visibility, decisions) + .mockResolvedValue({[questionNames.REPO_HOST]: any.word()}); - expect(await scaffoldVcsHost(hostScaffolders, {...otherOptions, host: chosenHost.toLowerCase()})).toEqual(results); + expect(await scaffoldVcsHost(hostPlugins, visibility, decisions, options)).toEqual({vcs: {}}); }); }); diff --git a/src/vcs/host/schema.js b/src/vcs/host/schema.js index ba9b0d6f..557f63fe 100644 --- a/src/vcs/host/schema.js +++ b/src/vcs/host/schema.js @@ -1,8 +1,4 @@ import joi from 'joi'; +import {optionsSchemas} from '@form8ion/core'; -export default joi.object().pattern(/^/, joi.object({ - scaffolder: joi.func().arity(1).required(), - prompt: joi.func().required(), - public: joi.bool(), - private: joi.bool() -})); +export default joi.object().pattern(/^/, optionsSchemas.form8ionPlugin); diff --git a/src/vcs/host/schema.test.js b/src/vcs/host/schema.test.js index 18a318fb..fb83e5be 100644 --- a/src/vcs/host/schema.test.js +++ b/src/vcs/host/schema.test.js @@ -13,7 +13,7 @@ describe('vcs-host plugins schema', () => { any.listOf(any.string), { factory: () => ({ - scaffolder: foo => foo, + scaffold: foo => foo, prompt: () => undefined, public: any.boolean(), private: any.boolean() @@ -31,40 +31,16 @@ describe('vcs-host plugins schema', () => { it('should require a `scaffolder` to be included', () => { expect(() => validateOptions(vcsHostSchema, {[key]: {}})) - .toThrowError(`"${key}.scaffolder" is required`); + .toThrowError(`"${key}.scaffold" is required`); }); it('should require `scaffolder` to be a function', () => { - expect(() => validateOptions(vcsHostSchema, {[key]: {scaffolder: any.word()}})) - .toThrowError(`"${key}.scaffolder" must be of type function`); + expect(() => validateOptions(vcsHostSchema, {[key]: {scaffold: any.word()}})) + .toThrowError(`"${key}.scaffold" must be of type function`); }); it('should require the scaffolder to accept a single argument', () => { - expect(() => validateOptions(vcsHostSchema, {[key]: {scaffolder: () => undefined}})) - .toThrowError(`"${key}.scaffolder" must have an arity of 1`); - }); - - it('should require a `prompt` property', () => { - expect(() => validateOptions(vcsHostSchema, {[key]: {scaffolder: foo => foo}})) - .toThrowError(`"${key}.prompt" is required`); - }); - - it('should require the `prompt` to be a function', () => { - expect(() => validateOptions(vcsHostSchema, {[key]: {scaffolder: foo => foo, prompt: any.word()}})) - .toThrowError(`"${key}.prompt" must be of type function`); - }); - - it('should require the `public` property to be a boolean', () => { - expect(() => validateOptions( - vcsHostSchema, - {[key]: {scaffolder: foo => foo, prompt: bar => bar, public: any.word()}} - )).toThrowError(`"${key}.public" must be a boolean`); - }); - - it('should require the `private` property to be a boolean', () => { - expect(() => validateOptions( - vcsHostSchema, - {[key]: {scaffolder: foo => foo, prompt: bar => bar, private: any.word()}} - )).toThrowError(`"${key}.private" must be a boolean`); + expect(() => validateOptions(vcsHostSchema, {[key]: {scaffold: () => undefined}})) + .toThrowError(`"${key}.scaffold" must have an arity of 1`); }); }); diff --git a/test/integration/features/scaffold/dependency-updaters.feature b/test/integration/features/scaffold/dependency-updaters.feature index 24170c1c..6ec63ef5 100644 --- a/test/integration/features/scaffold/dependency-updaters.feature +++ b/test/integration/features/scaffold/dependency-updaters.feature @@ -2,6 +2,7 @@ Feature: Dependency Updaters Scenario: Registered updater Given the project should be versioned in git + And the git repository will be hosted And a dependency updater can be chosen When the project is scaffolded Then the dependency updater was executed diff --git a/test/integration/features/step_definitions/common-steps.js b/test/integration/features/step_definitions/common-steps.js index 38d026a6..2dc6f9b2 100644 --- a/test/integration/features/step_definitions/common-steps.js +++ b/test/integration/features/step_definitions/common-steps.js @@ -53,21 +53,30 @@ When(/^the project is scaffolded$/, async function () { this.projectDescription = any.sentence(); await scaffold({ - languages: { - ...'Other' !== chosenLanguage && { - [chosenLanguage]: ({projectName}) => { - info(`Scaffolding ${chosenLanguage} language details for ${projectName}`); - - return this.languageScaffolderResults; + plugins: { + ...this.updatePlugin && { + dependencyUpdaters: { + [chosenUpdater]: this.updatePlugin } - } - }, - ...this.updaterScaffolderDetails && {dependencyUpdaters: {[chosenUpdater]: this.updaterScaffolderDetails}}, - ...vcsHost && { - vcsHosts: { - [vcsHost]: { - scaffolder: ({name, owner}) => ({sshUrl: this.remoteOriginUrl, name, owner}), - prompt: () => undefined + }, + languages: { + ...'Other' !== chosenLanguage && { + [chosenLanguage]: { + scaffold: ({projectName}) => { + info(`Scaffolding ${chosenLanguage} language details for ${projectName}`); + + return this.languageScaffolderResults; + } + } + } + }, + ...vcsHost && 'Other' !== vcsHost && { + vcsHosts: { + [vcsHost]: { + scaffold: ({projectName, owner}) => ({ + vcs: {sshUrl: this.remoteOriginUrl, name: projectName, owner, host: vcsHost} + }) + } } } }, @@ -84,7 +93,7 @@ When(/^the project is scaffolded$/, async function () { [questionNames.GIT_REPO]: repoShouldBeCreated ?? false, ...repoShouldBeCreated && {[questionNames.REPO_HOST]: vcsHost}, [questionNames.PROJECT_LANGUAGE]: chosenLanguage, - ...this.updaterScaffolderDetails && {[questionNames.DEPENDENCY_UPDATER]: chosenUpdater} + ...this.updatePlugin && {[questionNames.DEPENDENCY_UPDATER]: chosenUpdater} } }); }); diff --git a/test/integration/features/step_definitions/dependency-updater-steps.js b/test/integration/features/step_definitions/dependency-updater-steps.js index 8284e566..d8584f4a 100644 --- a/test/integration/features/step_definitions/dependency-updater-steps.js +++ b/test/integration/features/step_definitions/dependency-updater-steps.js @@ -1,10 +1,15 @@ +import {promises as fs} from 'node:fs'; + import {After, Before, Given, Then} from '@cucumber/cucumber'; import sinon from 'sinon'; +import any from '@travi/any'; +import {fileExists} from '@form8ion/core'; -let updaterScaffolder; +let updaterScaffolder, updaterLifter; Before(function () { updaterScaffolder = sinon.stub(); + updaterLifter = sinon.stub(); }); After(function () { @@ -12,13 +17,26 @@ After(function () { }); Given('a dependency updater can be chosen', async function () { - this.updaterScaffolderDetails = {scaffolder: foo => updaterScaffolder(foo)}; + const filename = any.word(); + this.updatePlugin = { + scaffold: async ({projectRoot}) => { + updaterScaffolder(); + await fs.writeFile(`${projectRoot}/${filename}.txt`, any.sentence()); + }, + lift: foo => { + updaterLifter(foo); + return any.simpleObject(); + }, + test: async ({projectRoot}) => fileExists(`${projectRoot}/${filename}.txt`) + }; }); Then('the dependency updater was executed', async function () { sinon.assert.calledOnce(updaterScaffolder); + sinon.assert.calledOnce(updaterLifter); }); Then('the dependency updater was not executed', async function () { sinon.assert.notCalled(updaterScaffolder); + sinon.assert.notCalled(updaterLifter); }); diff --git a/vitest.config.ts b/vitest.config.ts index 0f65ec76..0349add9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,3 @@ import {defineConfig} from 'vitest/config'; -export default defineConfig({test: {globals: true}}); +export default defineConfig({test: {globals: true, restoreMocks: true}});