diff --git a/bun.lockb b/bun.lockb index dfa2e8f..a859072 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/generators/constant.ts b/lib/generators/constant.ts index 3a75d76..36521ff 100644 --- a/lib/generators/constant.ts +++ b/lib/generators/constant.ts @@ -1,33 +1,38 @@ -import type { Answers } from "inquirer"; +import { input } from "@inquirer/prompts"; import type { AppendAction } from "../utils/actions"; -import type { GeneratorDefinition } from "../utils/generator"; +import type { GeneratorDefinition, QuestionsObject } from "../utils/generator"; import { stringEmpty } from "../utils/questions/validators"; -const generator: GeneratorDefinition = { +type ConstantAnswers = { + constantName: string; + constantValue: string; +}; + +const generator: GeneratorDefinition = { name: "Constant", description: "Create a new workspace constant", - questions: [ - { - type: "input", - name: "constantName", - message: "Constant:", - validate: stringEmpty, - }, - { - type: "input", - name: "constantValue", - message: "Value:", - default: "New Value", - validate: stringEmpty, - }, - ], + questions: { + constantName: () => + input({ + message: "Constant:", + required: true, + validate: stringEmpty, + }), + constantValue: () => + input({ + message: "Value:", + default: "New Value", + required: true, + validate: stringEmpty, + }), + } as QuestionsObject, actions: [ { type: "append", path: "architecture/workspace.dsl", pattern: /# Constants/, templateFile: "templates/constant.hbs", - } as AppendAction, + } as AppendAction, ], }; diff --git a/lib/generators/container.ts b/lib/generators/container.ts index 54f2c90..e8cf770 100644 --- a/lib/generators/container.ts +++ b/lib/generators/container.ts @@ -1,13 +1,15 @@ import { resolve } from "node:path"; +import { Separator, input, select } from "@inquirer/prompts"; import { file } from "bun"; import { kebabCase, pascalCase } from "change-case"; -import type { Answers, QuestionCollection } from "inquirer"; -import inquirer from "inquirer"; import type { AddAction, AppendAction } from "../utils/actions"; import type { GeneratorDefinition } from "../utils/generator"; import { removeSpaces } from "../utils/handlebars"; -import { getRelationships } from "../utils/questions/relationships"; -import { getSystemQuestion } from "../utils/questions/system"; +import { + type Relationship, + addRelationshipsToElement, +} from "../utils/questions/relationships"; +import { resolveSystemQuestion } from "../utils/questions/system"; import { chainValidators, duplicatedSystemName, @@ -16,83 +18,88 @@ import { } from "../utils/questions/validators"; import { getWorkspaceJson, getWorkspacePath } from "../utils/workspace"; -const generator: GeneratorDefinition = { +type ContainerAnswers = { + systemName: string; + elementName: string; + containerDescription: string; + containerType: string; + containerTechnology: string; + includeTabs: string; + includeSource: string; + relationships: Record; +}; + +const generator: GeneratorDefinition = { name: "Container", description: "Create a new system container", - questions: async (prompt, generator) => { + questions: async (generator) => { const workspaceInfo = await getWorkspaceJson( getWorkspacePath(generator.destPath), ); - const systemQuestion = await getSystemQuestion( + + const systemName = await resolveSystemQuestion( workspaceInfo ?? generator.destPath, - { - message: "Parent system:", - }, + { message: "Parent system:" }, ); - const questions: QuestionCollection = [ - systemQuestion, - { - type: "input", - name: "elementName", - message: "Container Name:", - validate: chainValidators( - stringEmpty, - duplicatedSystemName, - validateDuplicatedElements(workspaceInfo), - ), - }, - { - type: "input", - name: "containerDescription", - message: "Container Description:", - default: "Untitled Container", - validate: stringEmpty, - }, - { - type: "list", - name: "containerType", - message: "Container type:", - choices: [ - "EventBus", - "MessageBroker", - "Function", - "Database", - "WebApp", - "MobileApp", - "None of the above", - ], - }, - { - type: "input", - name: "containerTechnology", - message: "Container technology:", - }, - ]; + const elementName = await input({ + message: "Container Name:", + required: true, + validate: chainValidators<{ systemName: string }>( + stringEmpty, + duplicatedSystemName, + validateDuplicatedElements(workspaceInfo), + )({ systemName }), + }); + + const containerDescription = await input({ + message: "Container Description:", + default: "Untitled Container", + validate: stringEmpty, + }); + + const containerType = await select({ + message: "Container type:", + default: "None of the above", + choices: [ + { name: "EventBus", value: "EventBus" }, + { name: "MessageBroker", value: "MessageBroker" }, + { name: "Function", value: "Function" }, + { name: "Database", value: "Database" }, + { name: "WebApp", value: "WebApp" }, + { name: "MobileApp", value: "MobileApp" }, + { name: "None of the above", value: "None of the above" }, + ], + }); + + const containerTechnology = await input({ + message: "Container technology:", + }); const relationshipDefaults = { defaultRelationship: "Uses", defaultRelationshipType: "incoming", }; - const partialAnswers = await prompt(questions); - const relationships = await getRelationships( - partialAnswers.elementName, + const relationships = await addRelationshipsToElement( + elementName, workspaceInfo, - prompt, { filterChoices: (elm) => - elm instanceof inquirer.Separator || - elm.value !== partialAnswers.systemName, + elm instanceof Separator || elm.value !== systemName, ...relationshipDefaults, - includeContainers: partialAnswers.systemName, + includeContainers: systemName, }, ); const compiledAnswers = { - ...partialAnswers, + systemName, + elementName, + containerDescription, + containerType, + containerTechnology, includeTabs: "", - includeSource: `${kebabCase(partialAnswers.systemName)}.dsl`, + includeSource: `${kebabCase(systemName)}.dsl`, relationships, }; @@ -104,7 +111,7 @@ const generator: GeneratorDefinition = { path: "architecture/containers/{{kebabCase systemName}}/{{kebabCase elementName}}.dsl", skipIfExists: true, templateFile: "templates/containers/container.hbs", - } as AddAction, + } as AddAction, { type: "append", path: "architecture/relationships/_system.dsl", @@ -132,7 +139,7 @@ const generator: GeneratorDefinition = { }, pattern: /.*\n!include.*/, templateFile: "templates/include.hbs", - } as AppendAction, + } as AppendAction, { type: "append", path: "architecture/views/{{kebabCase systemName}}.dsl", @@ -157,13 +164,13 @@ const generator: GeneratorDefinition = { ); }, templateFile: "templates/views/container.hbs", - } as AppendAction, + } as AppendAction, { type: "append", createIfNotExists: true, path: "architecture/relationships/{{kebabCase systemName}}.dsl", templateFile: "templates/relationships/multiple.hbs", - } as AppendAction, + } as AppendAction, ], }; diff --git a/lib/generators/external-system.ts b/lib/generators/external-system.ts index af3dc07..b70d79c 100644 --- a/lib/generators/external-system.ts +++ b/lib/generators/external-system.ts @@ -1,14 +1,14 @@ -import type { Answers, QuestionCollection } from "inquirer"; -import inquirer from "inquirer"; +import { Separator, input } from "@inquirer/prompts"; import type { AppendAction } from "../utils/actions"; import { whenFileExists } from "../utils/actions/utils"; import type { GeneratorDefinition } from "../utils/generator"; import { + type Relationship, + addRelationshipsToElement, defaultParser, - getRelationships, - relationshipsForElement, + resolveRelationshipForElement, } from "../utils/questions/relationships"; -import { getSystemQuestion } from "../utils/questions/system"; +import { resolveSystemQuestion } from "../utils/questions/system"; import { chainValidators, duplicatedSystemName, @@ -17,66 +17,68 @@ import { } from "../utils/questions/validators"; import { getWorkspaceJson, getWorkspacePath } from "../utils/workspace"; -const generator: GeneratorDefinition = { +type ExternalSystemAnswers = { + systemName: string; + elementName: string; + extSystemDescription: string; + includeSource: string; + includeTabs: string; + relationships: Record; +}; + +const generator: GeneratorDefinition = { name: "External System", description: "Create a new external system", - questions: async (prompt, generator) => { + questions: async (generator) => { const workspaceInfo = await getWorkspaceJson( getWorkspacePath(generator.destPath), ); - const systemQuestion = await getSystemQuestion( + + const systemName = await resolveSystemQuestion( workspaceInfo ?? generator.destPath, ); - const questions: QuestionCollection = [ - systemQuestion, - { - type: "input", - name: "elementName", - message: "External system name:", - validate: chainValidators( - stringEmpty, - duplicatedSystemName, - validateDuplicatedElements(workspaceInfo), - ), - }, - { - type: "input", - name: "extSystemDescription", - message: "System description:", - default: "Untitled System", - }, - ]; + const elementName = await input({ + message: "External system name:", + required: true, + validate: chainValidators<{ systemName: string }>( + stringEmpty, + duplicatedSystemName, + validateDuplicatedElements(workspaceInfo), + )({ systemName }), + }); + + const extSystemDescription = await input({ + message: "System description:", + default: "Untitled System", + }); - const partialAnswers = await prompt(questions); const relationshipDefaults = { defaultRelationship: "Interacts with", defaultRelationshipType: "incoming", }; - const relationshipWithSystem = await prompt( - relationshipsForElement( - partialAnswers.systemName, - partialAnswers.elementName, - relationshipDefaults, - ), + const relationshipWithSystem = await resolveRelationshipForElement( + systemName, + elementName, + relationshipDefaults, ); const mainRelationship = defaultParser(relationshipWithSystem); - const relationships = await getRelationships( - partialAnswers.elementName, + const relationships = await addRelationshipsToElement( + elementName, workspaceInfo, - prompt, { filterChoices: (elm) => - elm instanceof inquirer.Separator || - elm.value !== partialAnswers.systemName, + elm instanceof Separator || elm.value !== systemName, ...relationshipDefaults, }, ); const compiledAnswers = { - ...partialAnswers, + systemName, + elementName, + extSystemDescription, includeSource: "relationships/_external.dsl", includeTabs: " ", relationships: { ...mainRelationship, ...relationships }, @@ -95,19 +97,19 @@ const generator: GeneratorDefinition = { path: "architecture/workspace.dsl", pattern: /# Relationships/, templateFile: "templates/include.hbs", - } as AppendAction, + } as AppendAction, { createIfNotExists: true, type: "append", path: "architecture/systems/_external.dsl", templateFile: "templates/system/external.hbs", - } as AppendAction, + } as AppendAction, { createIfNotExists: true, type: "append", path: "architecture/relationships/_external.dsl", templateFile: "templates/relationships/multiple.hbs", - } as AppendAction, + } as AppendAction, ], }; diff --git a/lib/generators/index.ts b/lib/generators/index.ts index 270c746..2312550 100644 --- a/lib/generators/index.ts +++ b/lib/generators/index.ts @@ -6,6 +6,7 @@ export { default as viewGenerator } from "./view"; export { default as systemGenerator } from "./system"; export { default as containerGenerator } from "./container"; export { default as relationshipGenerator } from "./relationship"; + // TODO: componentGenerator // - Available when the system finds containers // - Select from a list of containers diff --git a/lib/generators/person.ts b/lib/generators/person.ts index 46f9096..c65e4c4 100644 --- a/lib/generators/person.ts +++ b/lib/generators/person.ts @@ -1,14 +1,14 @@ -import type { Answers, QuestionCollection } from "inquirer"; -import inquirer from "inquirer"; +import { Separator, input } from "@inquirer/prompts"; import type { AppendAction } from "../utils/actions"; import { whenFileExists } from "../utils/actions/utils"; import type { GeneratorDefinition } from "../utils/generator"; import { + type Relationship, + addRelationshipsToElement, defaultParser, - getRelationships, - relationshipsForElement, + resolveRelationshipForElement, } from "../utils/questions/relationships"; -import { getSystemQuestion } from "../utils/questions/system"; +import { resolveSystemQuestion } from "../utils/questions/system"; import { chainValidators, stringEmpty, @@ -16,62 +16,66 @@ import { } from "../utils/questions/validators"; import { getWorkspaceJson, getWorkspacePath } from "../utils/workspace"; -const generator: GeneratorDefinition = { +type PersonAnswers = { + systemName: string; + personDescription: string; + elementName: string; + includeSource: string; + includeTabs: string; + relationships: Record; +}; + +const generator: GeneratorDefinition = { name: "Person", description: "Create a new person (customer, user, etc)", - questions: async (prompt, generator) => { + questions: async (generator) => { const workspaceInfo = await getWorkspaceJson( getWorkspacePath(generator.destPath), ); - const questions: QuestionCollection = [ - await getSystemQuestion(workspaceInfo ?? generator.destPath), - { - type: "input", - name: "elementName", - message: "Person name:", - validate: chainValidators( - stringEmpty, - validateDuplicatedElements(workspaceInfo), - ), - }, - { - type: "input", - name: "personDescription", - message: "Person description:", - default: "Default user", - }, - ]; + const systemName = await resolveSystemQuestion( + workspaceInfo ?? generator.destPath, + ); - const partialAnswers = await prompt(questions); - const relationshipDefaults = { - defaultRelationship: "Consumes", - defaultRelationshipType: "outgoing", - }; + const elementName = await input({ + message: "Person name:", + required: true, + validate: chainValidators( + stringEmpty, + validateDuplicatedElements(workspaceInfo), + )(), + }); - const relationshipWithSystem = await prompt( - relationshipsForElement( - partialAnswers.systemName, - partialAnswers.elementName, - relationshipDefaults, - ), + const personDescription = await input({ + message: "Person description:", + default: "Default user", + }); + + const relationshipWithSystem = await resolveRelationshipForElement( + systemName, + elementName, + { + defaultRelationship: "Consumes", + defaultRelationshipType: "outgoing", + }, ); const mainRelationship = defaultParser(relationshipWithSystem); - const relationships = await getRelationships( - partialAnswers.elementName, + const relationships = await addRelationshipsToElement( + elementName, workspaceInfo, - prompt, { filterChoices: (elm) => - elm instanceof inquirer.Separator || - elm.value !== partialAnswers.systemName, - ...relationshipDefaults, + elm instanceof Separator || elm.value !== systemName, + defaultRelationship: "Interacts with", + defaultRelationshipType: "outgoing", }, ); const compiledAnswers = { - ...partialAnswers, + systemName, + personDescription, + elementName, includeSource: "relationships/_people.dsl", includeTabs: " ", relationships: { ...mainRelationship, ...relationships }, @@ -90,19 +94,19 @@ const generator: GeneratorDefinition = { path: "architecture/workspace.dsl", pattern: /# Relationships/, templateFile: "templates/include.hbs", - } as AppendAction, + } as AppendAction, { type: "append", createIfNotExists: true, path: "architecture/systems/_people.dsl", templateFile: "templates/system/person.hbs", - } as AppendAction, + } as AppendAction, { createIfNotExists: true, type: "append", path: "architecture/relationships/_people.dsl", templateFile: "templates/relationships/multiple.hbs", - } as AppendAction, + } as AppendAction, ], }; diff --git a/lib/generators/relationship.ts b/lib/generators/relationship.ts index e4ce5af..cd03f4d 100644 --- a/lib/generators/relationship.ts +++ b/lib/generators/relationship.ts @@ -1,21 +1,32 @@ -import type { Answers } from "inquirer"; +import { select } from "@inquirer/prompts"; import type { AppendAction } from "../utils/actions"; import type { GeneratorDefinition } from "../utils/generator"; import { elementTypeByTags, labelElementByTags } from "../utils/labels"; -import { getRelationships } from "../utils/questions/relationships"; +import { + type Relationship, + addRelationshipsToElement, +} from "../utils/questions/relationships"; import { getAllSystemElements } from "../utils/questions/system"; import { getWorkspaceJson, getWorkspacePath } from "../utils/workspace"; -const generator: GeneratorDefinition = { +type RelationshipAnswers = { + elementName: string; + systemName?: string; + elementType: string; + relationships: Record; +}; + +const generator: GeneratorDefinition = { name: "Relationship", description: "Create a new relationship between elements", - questions: async (prompt, generator) => { + questions: async (generator) => { const workspaceInfo = await getWorkspaceJson( getWorkspacePath(generator.destPath), ); const systemElements = getAllSystemElements(workspaceInfo, { includeContainers: true, + includeDeploymentNodes: false, }).map((elm) => ({ name: `${labelElementByTags(elm.tags)} ${ elm.systemName ? `${elm.systemName}/` : "" @@ -27,26 +38,18 @@ const generator: GeneratorDefinition = { }, })); - const { element } = await prompt({ - type: "list", - name: "element", + const element = await select({ message: "Element:", choices: systemElements, }); - const relationships = await getRelationships( + const relationships = await addRelationshipsToElement( element.elementName, workspaceInfo, - prompt, { includeContainers: element.systemName ? element.systemName - : false, - validate: (input) => { - console.log("🦊", "input", input); - - return true; - }, + : undefined, }, ); @@ -71,7 +74,7 @@ const generator: GeneratorDefinition = { path: "architecture/relationships/_{{kebabCase elementType}}.dsl", pattern: /\n.* -> .*\n/, templateFile: "templates/relationships/multiple.hbs", - } as AppendAction, + } as AppendAction, { when: (answers) => Boolean(answers.systemName), skip: (answers) => @@ -82,7 +85,7 @@ const generator: GeneratorDefinition = { path: "architecture/relationships/{{kebabCase systemName}}.dsl", pattern: /\n.* -> .*\n/, templateFile: "templates/relationships/multiple.hbs", - } as AppendAction, + } as AppendAction, ], }; diff --git a/lib/generators/system.ts b/lib/generators/system.ts index f331d64..7687bf0 100644 --- a/lib/generators/system.ts +++ b/lib/generators/system.ts @@ -1,7 +1,10 @@ -import type { Answers, QuestionCollection } from "inquirer"; +import { input } from "@inquirer/prompts"; import type { AddAction, AppendAction } from "../utils/actions"; import type { GeneratorDefinition } from "../utils/generator"; -import { getRelationships } from "../utils/questions/relationships"; +import { + type Relationship, + addRelationshipsToElement, +} from "../utils/questions/relationships"; import { chainValidators, stringEmpty, @@ -9,49 +12,50 @@ import { } from "../utils/questions/validators"; import { getWorkspaceJson, getWorkspacePath } from "../utils/workspace"; -const generator: GeneratorDefinition = { +type SystemAnswers = { + systemName: string; + systemDescription: string; + elementName: string; + relationships: Record; +}; + +const generator: GeneratorDefinition = { name: "System", description: "Create a new software system", - questions: async (prompt, generator) => { + questions: async (generator) => { const workspaceInfo = await getWorkspaceJson( getWorkspacePath(generator.destPath), ); - const questions: QuestionCollection = [ - { - type: "input", - name: "systemName", - message: "System Name:", - validate: chainValidators( - stringEmpty, - validateDuplicatedElements(workspaceInfo), - ), - }, - { - type: "input", - name: "systemDescription", - message: "System Description:", - default: "Untitled System", - validate: stringEmpty, - }, - ]; + + const systemName = await input({ + message: "System Name:", + validate: chainValidators( + stringEmpty, + validateDuplicatedElements(workspaceInfo), + )(), + }); + + const systemDescription = await input({ + message: "System Description:", + default: "Untitled System", + validate: stringEmpty, + }); + const relationshipDefaults = { defaultRelationship: "Uses", defaultRelationshipType: "incoming", }; - const partialAnswers = await prompt(questions); - const relationships = await getRelationships( - partialAnswers.systemName, + const relationships = await addRelationshipsToElement( + systemName, workspaceInfo, - prompt, - { - ...relationshipDefaults, - }, + relationshipDefaults, ); const compiledAnswers = { - ...partialAnswers, - elementName: partialAnswers.systemName, + systemName, + systemDescription, + elementName: systemName, relationships, }; @@ -63,24 +67,24 @@ const generator: GeneratorDefinition = { skipIfExists: true, path: "architecture/systems/{{kebabCase systemName}}.dsl", templateFile: "templates/system/system.hbs", - } as AddAction, + } as AddAction, { type: "add", skipIfExists: true, path: "architecture/containers/{{kebabCase systemName}}/.gitkeep", templateFile: "templates/empty.hbs", - } as AddAction, + } as AddAction, { type: "append", path: "architecture/relationships/_system.dsl", pattern: /\n.* -> .*\n/, templateFile: "templates/relationships/multiple.hbs", - } as AppendAction, + } as AppendAction, { type: "add", path: "architecture/views/{{kebabCase systemName}}.dsl", templateFile: "templates/views/system.hbs", - } as AddAction, + } as AddAction, ], }; diff --git a/lib/generators/view.ts b/lib/generators/view.ts index 557c391..d9c501c 100644 --- a/lib/generators/view.ts +++ b/lib/generators/view.ts @@ -1,8 +1,8 @@ -import type { Answers, QuestionCollection } from "inquirer"; +import { input, select } from "@inquirer/prompts"; import type { AddAction } from "../utils/actions"; import { skipUnlessViewType, whenViewType } from "../utils/actions/utils"; import type { GeneratorDefinition } from "../utils/generator"; -import { getSystemQuestion } from "../utils/questions/system"; +import { resolveSystemQuestion } from "../utils/questions/system"; import { chainValidators, stringEmpty, @@ -10,59 +10,72 @@ import { } from "../utils/questions/validators"; import { getWorkspaceJson, getWorkspacePath } from "../utils/workspace"; +type ViewAnswers = { + viewType: string; + viewName: string; + viewDescription: string; + systemName?: string; + instanceDescription?: string; +}; + // TODO: Other types of views // - Dynamic // - Filtered // // - System landscape // // - Deployment -const generator: GeneratorDefinition = { +const generator: GeneratorDefinition = { name: "View", description: "Create a new view", - questions: async (prompt, generator) => { + questions: async (generator) => { const workspaceInfo = await getWorkspaceJson( getWorkspacePath(generator.destPath), ); - const questions: QuestionCollection = [ - { - type: "list", - name: "viewType", - message: "View type:", - choices: [ - // "dynamic", - // "filtered", - "deployment", - "landscape", - ], - }, - await getSystemQuestion(workspaceInfo ?? generator.destPath, { - message: "System (to create view for):", - when: (answers) => answers.viewType !== "landscape", - }), - { - type: "input", - name: "viewName", - message: "View name:", - validate: chainValidators( - stringEmpty, - validateDuplicatedViews(workspaceInfo), - ), - }, - { - type: "input", - name: "viewDescription", - message: "View description:", - default: "Untitled view", - }, - { - type: "input", - name: "instanceDescription", - message: "System Instance description:", - default: "System instance", - when: (answers) => answers.viewType === "deployment", - }, - ]; - return prompt(questions); + const viewType = await select({ + message: "View type:", + choices: [ + // "dynamic", + // "filtered", + { name: "Deployment", value: "deployment" }, + { name: "Landscape", value: "landscape" }, + ], + }); + + const systemName = + viewType !== "landscape" + ? await resolveSystemQuestion( + workspaceInfo ?? generator.destPath, + ) + : undefined; + + const viewName = await input({ + message: "View name:", + validate: chainValidators( + stringEmpty, + validateDuplicatedViews(workspaceInfo), + )(), + }); + + const viewDescription = await input({ + message: "View description:", + default: "Untitled view", + }); + + const instanceDescription = + viewType === "deployment" + ? await input({ + message: "System Instance description:", + default: "System instance", + }) + : undefined; + + return { + viewType, + viewName, + viewDescription, + systemName, + instanceDescription, + }; }, actions: [ { @@ -70,20 +83,20 @@ const generator: GeneratorDefinition = { type: "add", path: "architecture/views/{{kebabCase viewName}}.dsl", templateFile: "templates/views/deployment.hbs", - } as AddAction, + } as AddAction, { skip: skipUnlessViewType("deployment"), type: "add", path: "architecture/environments/{{kebabCase viewName}}.dsl", templateFile: "templates/environments/deployment.hbs", - } as AddAction, + } as AddAction, { when: whenViewType("landscape"), type: "add", skipIfExists: true, path: "architecture/views/{{kebabCase viewName}}.dsl", templateFile: "templates/views/landscape.hbs", - } as AddAction, + } as AddAction, ], }; diff --git a/lib/generators/workspace.ts b/lib/generators/workspace.ts index f109403..7ca8ef1 100644 --- a/lib/generators/workspace.ts +++ b/lib/generators/workspace.ts @@ -1,107 +1,112 @@ +import { confirm, input } from "@inquirer/prompts"; import { $ } from "bun"; -import type { Answers } from "inquirer"; import type { AddAction, AddManyAction } from "../utils/actions"; -import type { GeneratorDefinition } from "../utils/generator"; +import type { GeneratorDefinition, QuestionsObject } from "../utils/generator"; import { stringEmpty } from "../utils/questions/validators"; const globalUserName = await $`git config --global user.name`.text(); const globalUserEmail = await $`git config --global user.email`.text(); -const generator: GeneratorDefinition = { +type WorkspaceAnswers = { + workspaceName: string; + workspaceDescription: string; + systemName: string; + systemDescription: string; + authorName: string; + authorEmail: string; + shouldIncludeTheme: boolean; +}; + +const generator: GeneratorDefinition = { name: "Workspace", description: "Create a new workspace", - questions: [ - { - type: "input", - name: "workspaceName", - message: "Workspace name:", - validate: stringEmpty, - }, - { - type: "input", - name: "workspaceDescription", - message: "Workspace description:", - default: "Untitled Workspace", - }, - { - type: "input", - name: "systemName", - message: "System name:", - validate: stringEmpty, - }, - { - type: "input", - name: "systemDescription", - message: "System description:", - default: "Untitled System", - }, - { - type: "input", - name: "authorName", - message: "Author Name:", - default: globalUserName.trim(), - }, - { - type: "input", - name: "authorEmail", - message: "Author email:", - default: globalUserEmail.trim(), - }, - { - type: "confirm", - name: "shouldIncludeTheme", - message: "Include default theme?", - default: true, - }, - ], + questions: { + workspaceName: () => + input({ + message: "Workspace name:", + required: true, + validate: stringEmpty, + }), + workspaceDescription: () => + input({ + message: "Workspace description:", + default: "Untitled Workspace", + }), + systemName: () => + input({ + message: "System name:", + required: true, + validate: stringEmpty, + }), + systemDescription: () => + input({ + message: "System description:", + default: "Untitled System", + }), + authorName: () => + input({ + message: "Author Name:", + default: globalUserName.trim(), + }), + authorEmail: () => + input({ + message: "Author email:", + default: globalUserEmail.trim(), + }), + shouldIncludeTheme: () => + confirm({ + message: "Include default theme?", + default: true, + }), + } as QuestionsObject, actions: [ { type: "add", path: "architecture/workspace.dsl", templateFile: "templates/workspace.hbs", - } as AddAction, + } as AddAction, { type: "add", path: "architecture/systems/_system.dsl", templateFile: "templates/system/system.hbs", - } as AddAction, + } as AddAction, { type: "add", path: "architecture/containers/{{kebabCase systemName}}/.gitkeep", templateFile: "templates/empty.hbs", - } as AddAction, + } as AddAction, { type: "add", path: "architecture/relationships/_system.dsl", templateFile: "templates/empty.hbs", - } as AddAction, + } as AddAction, { type: "add", path: "architecture/.gitignore", templateFile: "templates/.gitignore", - } as AddAction, + } as AddAction, { type: "add", path: "architecture/.env-arch", templateFile: "templates/.env-arch", - } as AddAction, + } as AddAction, { type: "addMany", destination: "architecture", templateFiles: "templates/scripts/**/*.sh", skipIfExists: true, filePermissions: "744", - } as AddManyAction, + } as AddManyAction, { type: "addMany", destination: "architecture", templateFiles: "templates/**/.gitkeep", - } as AddManyAction, + } as AddManyAction, { type: "add", path: "architecture/views/{{kebabCase systemName}}.dsl", templateFile: "templates/views/system.hbs", - } as AddAction, + } as AddAction, ], }; diff --git a/lib/main.ts b/lib/main.ts index 9e3d290..e5b7280 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,8 +1,8 @@ import { relative, resolve } from "node:path"; +import { select } from "@inquirer/prompts"; +import { $ } from "bun"; import chalk from "chalk"; import { capitalCase } from "change-case"; -import inquirer from "inquirer"; -import type { Answers } from "inquirer"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import pkg from "../package.json"; @@ -27,6 +27,12 @@ const args = await yargs(hideBin(process.argv)) .option("dest", { default: ".", desc: "Target architecture folder", + }) + .option("export", { + alias: "e", + type: "boolean", + default: false, + desc: "Use structurizr-cli to export the workspace to JSON", }).argv; console.log( @@ -36,10 +42,17 @@ Create a Structurizr DSL scaffolding in seconds! `), ); -const prompt = inquirer.createPromptModule(); const destPath = resolve(process.cwd(), args.dest); const workspacePath = getWorkspacePath(destPath); +const exportWorkspace = async (path: string) => { + if (!args.export) return; + const workspacePath = getWorkspacePath(path); + if (!workspacePath) return; + + return $`structurizr-cli export -w ${workspacePath}/workspace.dsl -f json -o ${workspacePath} || true`; +}; + const { workspaceGenerator, ...otherGenerators } = generators; if (!workspacePath) { @@ -58,7 +71,10 @@ Let's create a new one by answering the questions below. destPath, }; - await createGenerator(prompt, generator); + await createGenerator(generator); + await exportWorkspace( + relative(process.cwd(), destPath) || process.cwd(), + ); process.exit(0); } catch (err) { console.error(err); @@ -72,30 +88,28 @@ console.log( )}\n`, ); -const mainPrompt = inquirer.createPromptModule(); -const generate = await mainPrompt<{ element: GeneratorDefinition }>([ - { - name: "element", - message: "Create a new element:", - type: "list", - choices: Object.values(otherGenerators) - .map((g) => ({ - name: `${labelElementByName(g.name)} ${g.name}`, - value: g, - })) - .toReversed() - .toSorted(), - }, -]); +const element = await select({ + message: "Create a new element:", + choices: Object.values(otherGenerators) + .map((g) => ({ + name: `${labelElementByName(g.name)} ${g.name}`, + value: g, + })) + .toReversed() + .toSorted(), +}); + +type GeneratorAnswers = GetAnswers; try { - const generator: Generator> = { - ...generate.element, + const generator: Generator = { + ...(element as GeneratorDefinition), templates, destPath, }; - await createGenerator(prompt, generator); + await createGenerator(generator); + await exportWorkspace(relative(process.cwd(), workspacePath)); process.exit(0); } catch (err) { console.error(err); diff --git a/lib/templates/constant.hbs b/lib/templates/constant.hbs index 710aeaa..6f46593 100644 --- a/lib/templates/constant.hbs +++ b/lib/templates/constant.hbs @@ -1 +1 @@ - !const {{upperCase constantName}} "{{{constantValue}}}" \ No newline at end of file + !const {{upperCase (underscoreSpaces constantName)}} "{{{constantValue}}}" \ No newline at end of file diff --git a/lib/utils/actions/add-many.ts b/lib/utils/actions/add-many.ts index 1e684a0..0b4c782 100644 --- a/lib/utils/actions/add-many.ts +++ b/lib/utils/actions/add-many.ts @@ -1,12 +1,11 @@ import { join } from "node:path"; import { Glob } from "bun"; import chalk from "chalk"; -import type { Answers } from "inquirer"; import type { BaseAction, ExtendedAction } from "."; import { ActionTypes, add } from "."; import { compileSource } from "../handlebars"; -export type AddManyAction = BaseAction & { +export type AddManyAction> = BaseAction & { type: ActionTypes.AddMany; destination: string; templateFiles: string; @@ -14,8 +13,8 @@ export type AddManyAction = BaseAction & { skipIfExists?: boolean; }; -export async function addMany( - options: ExtendedAction & AddManyAction, +export async function addMany>( + options: ExtendedAction & AddManyAction, answers: A, ): Promise { const { @@ -44,7 +43,7 @@ export async function addMany( return false; } - const compiledOpts = compileSource(opts, answers); + const compiledOpts = compileSource>(opts, answers); const pattern = new Glob(compiledOpts.templateFiles); const filesToCreate = []; diff --git a/lib/utils/actions/add.ts b/lib/utils/actions/add.ts index 86e26e6..78eccf9 100644 --- a/lib/utils/actions/add.ts +++ b/lib/utils/actions/add.ts @@ -1,11 +1,10 @@ import { join, relative, resolve } from "node:path"; import { $, file, write } from "bun"; import chalk from "chalk"; -import type { Answers } from "inquirer"; import type { ActionTypes, BaseAction, ExtendedAction } from "."; import { compileSource, compileTemplateFile } from "../handlebars"; -export type AddAction = BaseAction & { +export type AddAction> = BaseAction & { type: ActionTypes.Add; templateFile: string; path: string; @@ -13,8 +12,8 @@ export type AddAction = BaseAction & { skipIfExists?: boolean; }; -export async function add( - options: ExtendedAction & AddAction, +export async function add>( + options: ExtendedAction & AddAction, answers: A, ): Promise { const { @@ -43,7 +42,7 @@ export async function add( return false; } - const compiledOpts = compileSource(opts, answers); + const compiledOpts = compileSource>(opts, answers); const targetFile = file(resolve(rootPath, compiledOpts.path)); const relativePath = relative( diff --git a/lib/utils/actions/append.ts b/lib/utils/actions/append.ts index b95ab4f..a6e236e 100644 --- a/lib/utils/actions/append.ts +++ b/lib/utils/actions/append.ts @@ -2,11 +2,10 @@ import { access } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { $, file, write } from "bun"; import chalk from "chalk"; -import type { Answers } from "inquirer"; import type { ActionTypes, BaseAction, ExtendedAction } from "."; import { compileSource, compileTemplateFile } from "../handlebars"; -export type AppendAction = BaseAction & { +export type AppendAction> = BaseAction & { type: ActionTypes.Append; templateFile: string; path: string; @@ -15,8 +14,8 @@ export type AppendAction = BaseAction & { pattern?: RegExp; }; -export async function append( - options: ExtendedAction & AppendAction, +export async function append>( + options: ExtendedAction & AppendAction, answers: A, ): Promise { const { @@ -47,7 +46,7 @@ export async function append( return false; } - const compiledOpts = compileSource(opts, answers); + const compiledOpts = compileSource>(opts, answers); const targetFilePath = resolve(rootPath, compiledOpts.path); const relativePath = relative(process.cwd(), targetFilePath); const targetFile = file(targetFilePath); diff --git a/lib/utils/actions/index.ts b/lib/utils/actions/index.ts index 888e06a..a06e260 100644 --- a/lib/utils/actions/index.ts +++ b/lib/utils/actions/index.ts @@ -1,19 +1,17 @@ -import type { Answers } from "inquirer"; - export enum ActionTypes { Add = "add", AddMany = "addMany", Append = "append", } -declare function whenOrSkip( +declare function whenOrSkip>( answers: A, rootPath: string, ): boolean | string | Promise; -export type BaseAction = { - when?: typeof whenOrSkip; - skip?: typeof whenOrSkip; +export type BaseAction> = { + when?: typeof whenOrSkip; + skip?: typeof whenOrSkip; }; export type ExtendedAction = { diff --git a/lib/utils/actions/utils.ts b/lib/utils/actions/utils.ts index c993f4f..e6ceaf1 100644 --- a/lib/utils/actions/utils.ts +++ b/lib/utils/actions/utils.ts @@ -1,12 +1,15 @@ import { resolve } from "node:path"; import { file } from "bun"; -import type { Answers } from "inquirer"; -export const skipUnlessViewType = (type: string) => (answer: Answers) => - answer.viewType !== type && `View type "${type}" not selected.`; +export const skipUnlessViewType = + >(type: string) => + (answer: A) => + answer.viewType !== type && `View type "${type}" not selected.`; -export const whenViewType = (type: string) => (answer: Answers) => - answer.viewType === type; +export const whenViewType = + >(type: string) => + (answer: A) => + answer.viewType === type; export const whenFileExists = async ( filePath: string, diff --git a/lib/utils/generator.test.ts b/lib/utils/generator.test.ts index 75ae34d..4a95a7c 100644 --- a/lib/utils/generator.test.ts +++ b/lib/utils/generator.test.ts @@ -1,5 +1,5 @@ import { describe, expect, mock, test } from "bun:test"; -import type { PromptModule } from "inquirer"; +import { CancelablePromise } from "@inquirer/type"; import templates from "../templates/bundle"; import type { AddAction } from "./actions"; import type { Generator } from "./generator"; @@ -8,10 +8,15 @@ import { createGenerator } from "./generator"; describe("generator", () => { describe("createGenerator", () => { test("should create generator and execute actions", async () => { - const prompt = mock(() => Promise.resolve({ answer: 123 })); + const prompt = mock( + () => + new CancelablePromise((resolve) => resolve("123")), + ); const execute = mock(); - const questions = [{ type: "input", name: "question" }]; - const actions = [{ type: "add" } as AddAction]; + const questions = { + answer: prompt, + }; + const actions = [{ type: "add" } as AddAction<{ answer: string }>]; const definition: Generator<{ answer: string }> = { name: "Test", @@ -23,38 +28,34 @@ describe("generator", () => { }; expect( - async () => - await createGenerator( - prompt as unknown as PromptModule, - definition, - execute, - ), + async () => await createGenerator(definition, execute), ).not.toThrow(); - expect(prompt).toHaveBeenCalledWith(questions); expect(execute).toHaveBeenCalled(); expect(execute.mock.lastCall).toEqual([ expect.objectContaining({ ...actions[0], }), - { answer: 123 }, + { answer: "123" }, ]); }); test("should create generator from function questions", async () => { - const prompt = mock(() => Promise.resolve({ answer: 123 })); + const prompt = mock( + () => + new CancelablePromise((resolve) => resolve("123")), + ); const execute = mock(); - const questionsArr = [{ type: "input", name: "question" }]; - const questions = ( - promptMock: ( - questions: unknown[], - ) => Promise<{ answer: string }>, - ) => { - return promptMock(questionsArr); + const questions = async () => { + return { + question: await prompt(), + }; }; - const actions = [{ type: "add" } as AddAction]; + const actions = [ + { type: "add" } as AddAction<{ question: string }>, + ]; - const definition: Generator<{ answer: string }> = { + const definition: Generator<{ question: string }> = { name: "Test", description: "Test Generator", destPath: `${import.meta.dirname}/workspace.dsl`, @@ -64,21 +65,16 @@ describe("generator", () => { }; expect( - async () => - await createGenerator( - prompt as unknown as PromptModule, - definition, - execute, - ), + async () => await createGenerator(definition, execute), ).not.toThrow(); - expect(prompt).toHaveBeenCalledWith(questionsArr); + expect(prompt).toHaveBeenCalled(); expect(execute).toHaveBeenCalled(); expect(execute.mock.lastCall).toEqual([ expect.objectContaining({ ...actions[0], }), - { answer: 123 }, + { question: "123" }, ]); }); @@ -93,38 +89,33 @@ describe("generator", () => { setTimeout(() => done(true), randomTime); }), ); - const questionsArr = [{ type: "input", name: "question" }]; - const questions = ( - promptMock: ( - questions: unknown[], - ) => Promise<{ answer: string }>, - ) => { - return promptMock(questionsArr); + const questions = async () => { + return await prompt(); }; const actions = [ { type: "add", path: "path1", templateFile: "templateFile", - } as AddAction, + } as AddAction<{ answer: number }>, { type: "add", path: "path2", templateFile: "templateFile", - } as AddAction, + } as AddAction<{ answer: number }>, { type: "add", path: "path3", templateFile: "templateFile", - } as AddAction, + } as AddAction<{ answer: number }>, { type: "add", path: "path4", templateFile: "templateFile", - } as AddAction, + } as AddAction<{ answer: number }>, ]; - const definition: Generator<{ answer: string }> = { + const definition: Generator<{ answer: number }> = { name: "Test", description: "Test Generator", destPath: `${import.meta.dirname}/workspace.dsl`, @@ -134,15 +125,10 @@ describe("generator", () => { }; expect( - async () => - await createGenerator( - prompt as unknown as PromptModule, - definition, - execute, - ), + async () => await createGenerator(definition, execute), ).not.toThrow(); - expect(prompt).toHaveBeenCalledWith(questionsArr); + expect(prompt).toHaveBeenCalled(); expect(execute).toHaveBeenCalled(); expect(execute.mock.calls.map(([args]) => args.path)).toEqual([ "path1", diff --git a/lib/utils/generator.ts b/lib/utils/generator.ts index 2be4510..b379912 100644 --- a/lib/utils/generator.ts +++ b/lib/utils/generator.ts @@ -1,5 +1,5 @@ +import type { CancelablePromise } from "@inquirer/type"; import chalk from "chalk"; -import type { Answers, PromptModule, QuestionCollection } from "inquirer"; import type { AddAction, AddManyAction, @@ -8,26 +8,35 @@ import type { } from "./actions"; import { ActionTypes, add, addMany, append } from "./actions"; -export type GeneratorDefinition = { +/** + * Added support for latest inquirer API + */ +export type QuestionsObject = { + [key: string]: () => CancelablePromise; +}; + +export type GeneratorDefinition< + A extends Record = Record, +> = { name: string; description: string; - questions: - | QuestionCollection - | ((prompt: PromptModule, generator: Generator) => Promise); - actions: (AddAction | AddManyAction | AppendAction)[]; + questions: ((generator: Generator) => Promise) | QuestionsObject; + actions: (AddAction | AddManyAction | AppendAction)[]; }; -export type Generator = GeneratorDefinition & { - destPath: string; - templates: Map; -}; +export type Generator> = + GeneratorDefinition & { + destPath: string; + templates: Map; + }; export type GetAnswers = Type extends GeneratorDefinition ? X : null; -async function executeAction( - action: ExtendedAction & (AddAction | AddManyAction | AppendAction), +async function executeAction>( + action: ExtendedAction & + (AddAction | AddManyAction | AppendAction), answers: A, ): Promise { switch (action.type) { @@ -46,19 +55,33 @@ async function executeAction( } } -export async function createGenerator( - prompt: PromptModule, +export async function createGenerator>( generator: Generator, execute = executeAction, ): Promise { console.log(chalk.bold(chalk.gray(generator.description))); + const answers = generator.questions instanceof Function - ? await generator.questions(prompt, generator) - : await prompt(generator.questions); + ? await generator.questions(generator) + : ((await Object.entries( + generator.questions as QuestionsObject, + ).reduce( + async (answers, [name, prompt]) => { + const acc = await answers; + + const answer = await prompt?.(); + + return { + ...acc, + [name]: answer, + }; + }, + Promise.resolve({} as Record), + )) as A); for await (const action of generator.actions) { - await execute( + await execute( { ...action, rootPath: generator.destPath, diff --git a/lib/utils/handlebars.ts b/lib/utils/handlebars.ts index 2cc566f..47e2483 100644 --- a/lib/utils/handlebars.ts +++ b/lib/utils/handlebars.ts @@ -4,6 +4,7 @@ import { kebabCase, pascalCase } from "change-case"; import Handlebars from "handlebars"; export const removeSpaces = (txt = "") => txt.replace(/\s/g, ""); +export const underscoreSpaces = (txt = "") => txt.replace(/\s/g, "_"); Handlebars.registerHelper("kebabCase", (target) => kebabCase(target)); Handlebars.registerHelper("properCase", (target) => pascalCase(target)); @@ -12,6 +13,9 @@ Handlebars.registerHelper("upperCase", (target = "") => target.toUpperCase()); Handlebars.registerHelper("lowerCase", (target = "") => target.toLowerCase()); Handlebars.registerHelper("eq", (arg1, arg2) => arg1 === arg2); Handlebars.registerHelper("removeSpaces", (txt = "") => removeSpaces(txt)); +Handlebars.registerHelper("underscoreSpaces", (txt = "") => + underscoreSpaces(txt), +); export function compileSource>( sourceObject: Record, diff --git a/lib/utils/labels.ts b/lib/utils/labels.ts index 7ec410c..5bbb0d8 100644 --- a/lib/utils/labels.ts +++ b/lib/utils/labels.ts @@ -4,6 +4,7 @@ export enum Labels { External = "⬜️", Person = "👤", System = "🟦", + DeploymentNode = "🟧", Relationship = "⇢ ", View = "🔳", } @@ -13,6 +14,7 @@ export const labelElementByTags = (tags: string): string => { if (tag === "Person") return Labels.Person; if (tag === "External") return Labels.External; if (tag === "Container") return Labels.Container; + if (tag === "Deployment Node") return Labels.DeploymentNode; } return Labels.System; @@ -23,6 +25,7 @@ export const elementTypeByTags = (tags: string): string => { if (tag === "Person") return "Person"; if (tag === "External") return "External"; if (tag === "Container") return "Container"; + if (tag === "Deployment Node") return "DeploymentNode"; } return "System"; diff --git a/lib/utils/questions/relationships.test.ts b/lib/utils/questions/relationships.test.ts deleted file mode 100644 index 8804b12..0000000 --- a/lib/utils/questions/relationships.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import type { PromptModule } from "inquirer"; -import inquirer from "inquirer"; -import type { StructurizrWorkspace } from "../workspace"; -import { getRelationships } from "./relationships"; - -describe("relationships", () => { - describe("getRelationships", () => { - type Model = StructurizrWorkspace["model"]; - type SoftwareElement = Model["people"][number]; - - test("should return empty when no workspaceInfo passed", async () => { - const relationships = await getRelationships( - "someElement", - undefined, - mock() as unknown as PromptModule, - ); - - expect(relationships).toEqual({}); - }); - test("should return empty when no system elements found", async () => { - const prompt = mock().mockResolvedValue({ relationships: [] }); - - const relationships = await getRelationships( - "someElement", - { - model: { - people: [] as SoftwareElement[], - softwareSystems: [] as SoftwareElement[], - deploymentNodes: [ - { - id: "123", - tags: "Element,Deployment Node", - name: "SomeDeploymentNode", - }, - ] as SoftwareElement[], - }, - } as StructurizrWorkspace, - prompt as unknown as PromptModule, - ); - - expect(relationships).toEqual({}); - expect(prompt).not.toHaveBeenCalled(); - }); - - test("should return empty when no elements flagged for relationship", async () => { - const prompt = mock().mockResolvedValue({ relationships: [] }); - - const relationships = await getRelationships( - "someElement", - { - model: { - people: [ - { - id: "123", - tags: "Element,Person", - name: "SomePerson", - }, - ] as SoftwareElement[], - softwareSystems: [ - { - id: "123", - tags: "Element,SoftwareSystem", - name: "SomeSystem", - }, - ] as SoftwareElement[], - deploymentNodes: [ - { - id: "123", - tags: "Element,Deployment Node", - name: "SomeDeploymentNode", - }, - ] as SoftwareElement[], - }, - } as StructurizrWorkspace, - prompt as unknown as PromptModule, - ); - - expect(prompt).toHaveBeenCalledWith({ - type: "checkbox", - name: "relationships", - message: expect.any(String), - choices: [ - expect.any(inquirer.Separator), - { - name: "🟦 SomeSystem", - value: "SomeSystem", - }, - expect.any(inquirer.Separator), - { - name: "👤 SomePerson", - value: "SomePerson", - }, - ], - when: expect.any(Function), - validate: expect.any(Function), - }); - - expect(relationships).toEqual({}); - }); - - test("should get relationships correctly", async () => { - const prompt = mock() - .mockResolvedValueOnce({ - relationships: ["SomePerson", "SomeSystem"], - }) - .mockResolvedValueOnce({ - SomePerson_relationshipType: "outgoing", - SomePerson_relationship: "Consumes", - SomePerson_technology: "Web/HTTP", - SomeSystem_relationshipType: "outgoing", - SomeSystem_relationship: "Consumes", - SomeSystem_technology: "Web/HTTP", - }); - - const relationships = await getRelationships( - "someElement", - { - model: { - people: [ - { - id: "123", - tags: "Element,Person", - name: "SomePerson", - }, - ] as SoftwareElement[], - softwareSystems: [ - { - id: "123", - tags: "Element,SoftwareSystem", - name: "SomeSystem", - }, - ] as SoftwareElement[], - deploymentNodes: [ - { - id: "123", - tags: "Element,Deployment Node", - name: "SomeDeploymentNode", - }, - ] as SoftwareElement[], - }, - } as StructurizrWorkspace, - prompt as unknown as PromptModule, - ); - - expect(prompt).toHaveBeenCalledTimes(2); - expect(prompt).toHaveBeenLastCalledWith([ - { - type: "list", - name: "SomePerson_relationshipType", - message: expect.any(String), - choices: expect.any(Array), - default: expect.any(String), - }, - { - type: "input", - name: "SomePerson_relationship", - message: expect.any(String), - default: expect.any(String), - }, - { - type: "input", - name: "SomePerson_technology", - message: expect.any(String), - default: expect.any(String), - }, - { - type: "list", - name: "SomeSystem_relationshipType", - message: expect.any(String), - choices: expect.any(Array), - default: expect.any(String), - }, - { - type: "input", - name: "SomeSystem_relationship", - message: expect.any(String), - default: expect.any(String), - }, - { - type: "input", - name: "SomeSystem_technology", - message: expect.any(String), - default: expect.any(String), - }, - ]); - - expect(relationships).toEqual({ - SomePerson: { - relationshipType: "outgoing", - relationship: "Consumes", - technology: "Web/HTTP", - }, - SomeSystem: { - relationshipType: "outgoing", - relationship: "Consumes", - technology: "Web/HTTP", - }, - }); - }); - }); -}); diff --git a/lib/utils/questions/relationships.ts b/lib/utils/questions/relationships.ts index 1151a0e..b1d70fe 100644 --- a/lib/utils/questions/relationships.ts +++ b/lib/utils/questions/relationships.ts @@ -1,11 +1,11 @@ +import { Separator, checkbox, input, select } from "@inquirer/prompts"; import { pascalCase } from "change-case"; -import type { Answers, PromptModule, Question } from "inquirer"; -import inquirer from "inquirer"; +import type { QuestionsObject } from "../generator"; import { removeSpaces } from "../handlebars"; import { labelElementByTags } from "../labels"; import type { StructurizrWorkspace } from "../workspace"; -type Relationship = { +export type Relationship = { relationship: string; relationshipType: string; technology: string; @@ -21,11 +21,10 @@ type Model = StructurizrWorkspace["model"]; type SoftwareElement = Model["people"][number]; type SoftwareSystem = Model["softwareSystems"][number]; -type GetRelationshipsOptions = { - when?: Question["when"]; - validate?: Question["validate"]; +type AddRelationshipOptions = { + validate?: Parameters[0]["validate"]; filterChoices?: ( - elm: inquirer.Separator | { name: string; value: string }, + elm: Separator | { name: string; value: string }, pos: number, arr: unknown[], ) => boolean; @@ -47,7 +46,28 @@ export const defaultParser = (rawRelationshipMap: Record) => { ); }; -export const relationshipsForElement = ( +const resolveRelationshipPromises = async ( + relationshipPromises: QuestionsObject, +): Promise> => { + const relationshipMap = await Object.entries(relationshipPromises).reduce( + async (answers, [name, prompt]) => { + const acc = await answers; + const answer = await prompt?.(); + + if (!answer) return acc; + + return { + ...acc, + [name]: answer, + }; + }, + Promise.resolve({} as { [key: string]: string }), + ); + + return relationshipMap; +}; + +export const resolveRelationshipForElement = async ( relationshipName: string, elementName: string, { @@ -55,39 +75,38 @@ export const relationshipsForElement = ( defaultRelationshipType = "incoming", defaultTechnology = "Web/HTTP", }: RelationshipForElementOptions = {}, -) => { +): Promise> => { const elementNamePascalCase = pascalCase(removeSpaces(relationshipName)); - return [ - { - type: "list", - name: `${elementNamePascalCase}_relationshipType`, - message: `Relationship type for ${relationshipName}`, - choices: [ - { - name: `outgoing (${elementName} → ${relationshipName})`, - value: "outgoing", - }, - { - name: `incoming (${relationshipName} → ${elementName})`, - value: "incoming", - }, - ], - default: defaultRelationshipType, - }, - { - type: "input", - name: `${elementNamePascalCase}_relationship`, - message: `Relationship with ${relationshipName}:`, - default: defaultRelationship, - }, - { - type: "input", - name: `${elementNamePascalCase}_technology`, - message: "Technology:", - default: defaultTechnology, - }, - ]; + const relationshipPromises = { + [`${elementNamePascalCase}_relationshipType`]: () => + select({ + message: `Relationship type for ${relationshipName}`, + choices: [ + { + name: `outgoing (${elementName} → ${relationshipName})`, + value: "outgoing", + }, + { + name: `incoming (${relationshipName} → ${elementName})`, + value: "incoming", + }, + ], + default: defaultRelationshipType, + }), + [`${elementNamePascalCase}_relationship`]: () => + input({ + message: `Relationship with ${relationshipName}:`, + default: defaultRelationship, + }), + [`${elementNamePascalCase}_technology`]: () => + input({ + message: "Technology:", + default: defaultTechnology, + }), + }; + + return resolveRelationshipPromises(relationshipPromises); }; const findSystemContainers = ( @@ -103,20 +122,18 @@ const findSystemContainers = ( const separator = ( name: string, elements: unknown[], -): inquirer.Separator | unknown[] => { +): Separator | unknown[] => { const maybeSeparator = elements.length - ? new inquirer.Separator(`-- ${name} --`) + ? new Separator(`-- ${name} --`) : []; return maybeSeparator; }; -export async function getRelationships( +export async function addRelationshipsToElement( elementName: string, workspaceInfo: StructurizrWorkspace | undefined, - prompt: PromptModule, { - when = () => true, validate = () => true, filterChoices = () => true, parse = defaultParser, @@ -125,7 +142,7 @@ export async function getRelationships( defaultRelationshipType = "outgoing", defaultTechnology = "Web/HTTP", includeContainers, - }: GetRelationshipsOptions = {}, + }: AddRelationshipOptions = {}, ): Promise> { if (!workspaceInfo) return {}; @@ -146,11 +163,11 @@ export async function getRelationships( ...softwareSystems, separator("People", people), ...people, - ] as (SoftwareElement | inquirer.Separator)[] + ] as (SoftwareElement | Separator)[] ) .flat() .map((elm) => - elm instanceof inquirer.Separator + elm instanceof Separator ? elm : { name: `${labelElementByTags(elm.tags)} ${elm.name}`, @@ -159,32 +176,33 @@ export async function getRelationships( ) .filter(filterChoices); - if ( - !systemElements.filter((elm) => !(elm instanceof inquirer.Separator)) - .length - ) + if (!systemElements.filter((elm) => !(elm instanceof Separator)).length) return {}; - const { relationships } = await prompt({ - type: "checkbox", - name: "relationships", + const relationshipNames = await checkbox({ message, choices: systemElements, - when, validate, }); - if (!relationships.length) return {}; + if (!relationshipNames.length) return {}; - const relationshipQuestions = relationships.flatMap((name: string) => { - return relationshipsForElement(name, elementName, { - defaultRelationship, - defaultRelationshipType, - defaultTechnology, - }); - }); + const relationshipsMap: Record = {}; + + for await (const relationshipName of relationshipNames) { + const relationship = await resolveRelationshipForElement( + relationshipName, + elementName, + { defaultRelationship, defaultRelationshipType, defaultTechnology }, + ); + + const elementNamePascalCase = pascalCase( + removeSpaces(relationshipName), + ); - const relationshipMap = await prompt(relationshipQuestions); + relationshipsMap[elementNamePascalCase] = + parse(relationship)[elementNamePascalCase]; + } - return parse(relationshipMap); + return relationshipsMap; } diff --git a/lib/utils/questions/system.ts b/lib/utils/questions/system.ts index 69fb040..cf1a8f6 100644 --- a/lib/utils/questions/system.ts +++ b/lib/utils/questions/system.ts @@ -1,29 +1,36 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; +import { input, select } from "@inquirer/prompts"; +import { CancelablePromise } from "@inquirer/type"; import { kebabCase } from "change-case"; -import type { Answers, AsyncDynamicQuestionProperty, Question } from "inquirer"; import { getWorkspacePath } from "../workspace"; import type { StructurizrWorkspace } from "../workspace"; -type GetSystemQuestionOptions = { - when?: AsyncDynamicQuestionProperty; - message?: string; -}; - type SoftwareElement = StructurizrWorkspace["model"]["people"][number]; type SoftwareSystem = StructurizrWorkspace["model"]["softwareSystems"][number]; +type DeploymentNode = StructurizrWorkspace["model"]["deploymentNodes"][number]; type GetAllSystemElementsOptions = { includeContainers?: boolean; + includeDeploymentNodes?: boolean; }; +// TODO: Test filtering logic export function getAllSystemElements( workspaceInfo: StructurizrWorkspace | undefined, - { includeContainers = true }: GetAllSystemElementsOptions = {}, -): (SoftwareElement & { systemName?: string })[] { + { + includeContainers = true, + includeDeploymentNodes = false, + }: GetAllSystemElementsOptions = {}, +): ((SoftwareElement | DeploymentNode) & { systemName?: string })[] { if (!workspaceInfo) return []; const systemElements = Object.values(workspaceInfo.model) .flat() + .filter((elm) => + !includeDeploymentNodes + ? !elm.tags.split(",").includes("Deployment Node") + : true, + ) .flatMap((elm) => { const sysElm = elm as SoftwareSystem; if (includeContainers && sysElm.containers) { @@ -42,46 +49,38 @@ export function getAllSystemElements( return systemElements; } -export async function getSystemQuestion( +export function resolveSystemQuestion( workspace: string | StructurizrWorkspace, - { - when = () => true, - message = "Relates to system:", - }: GetSystemQuestionOptions = {}, -): Promise { + options: { message: string } = { + message: "Relates to system:", + }, +): CancelablePromise { + const voidPromise: CancelablePromise = new CancelablePromise( + (resolve) => resolve(""), + ); const workspaceInfo = typeof workspace !== "string" && workspace; if (workspaceInfo) { const systems = (workspaceInfo.model?.softwareSystems ?? []) .filter((system) => !system.tags.split(",").includes("External")) - .map((system) => system.name); + .map((system) => ({ name: system.name, value: system.name })); - const systemQuestion = { - type: "list", - name: "systemName", - message, + const systemQuestion = select({ + message: options.message, choices: systems, - when, - }; + }); return systemQuestion; } const workspacePath = typeof workspace === "string" && workspace; - if (!workspacePath) return {}; + if (!workspacePath) return voidPromise; const workspaceFolder = getWorkspacePath(workspacePath); - const systemQuestion: Question = { - type: "input", - name: "systemName", - message, - when, - validate: (input, answers) => { - if (!answers) return true; - - answers.systemName = input; - + return input({ + message: options.message, + validate: async (input) => { if (workspaceFolder) { const systemPath = resolve( workspaceFolder, @@ -94,7 +93,5 @@ export async function getSystemQuestion( `System "${input}" does not exist in the workspace.`, ); }, - }; - - return systemQuestion; + }); } diff --git a/lib/utils/questions/validators.test.ts b/lib/utils/questions/validators.test.ts index da92448..dd29905 100644 --- a/lib/utils/questions/validators.test.ts +++ b/lib/utils/questions/validators.test.ts @@ -10,36 +10,28 @@ import { describe("validators", () => { describe("chainValidators", () => { test("should chain validators correctly", async () => { - const validate = chainValidators( - (input) => input.size > 0, - async (input) => input.weight > 0 || "Error message", - (input) => input.height > 0, - ); + const validate = chainValidators<{ + size?: number; + weight?: number; + height?: number; + test?: string; + }>( + (input) => input.length > 0, + async (input) => input.endsWith("test") || "Error message", + (input) => input.startsWith("test"), + (input, answers) => answers?.test === input, + )({ test: "testtest" }); - const response1 = await validate?.({ - size: 1, - weight: 1, - height: 1, - }); + const response1 = await validate?.("testtest"); expect(response1).toBeTrue(); - const response2 = await validate?.({ - size: 1, - weight: 1, - height: 0, - }); + const response2 = await validate?.("endsWith_test"); expect(response2).toBeFalse(); - const response3 = await validate?.({ - size: 1, - weight: 0, - height: 1, - }); + const response3 = await validate?.("testable"); expect(response3).toEqual("Error message"); - const response4 = await validate?.({ - size: 0, - weight: 1, - height: 1, - }); + const response4 = await validate?.(""); expect(response4).toBeFalse(); + const response5 = await validate?.("testtosttest"); + expect(response5).toBeFalse(); }); }); describe("duplicatedSystemName", () => { diff --git a/lib/utils/questions/validators.ts b/lib/utils/questions/validators.ts index cf19edd..7b745dd 100644 --- a/lib/utils/questions/validators.ts +++ b/lib/utils/questions/validators.ts @@ -1,15 +1,23 @@ import { kebabCase, pascalCase } from "change-case"; -import type { Answers, Question } from "inquirer"; import { removeSpaces } from "../handlebars"; import type { StructurizrWorkspace } from "../workspace"; import { getAllSystemElements } from "./system"; -type Validator = Question["validate"]; +type Validator = Record> = ( + input: string, + answers?: A, +) => string | boolean | Promise; -export const stringEmpty = (input: string) => input.length > 0; +export const stringEmpty = (input: string) => input?.length > 0; -export const duplicatedSystemName = (input: string, answers: Answers) => { - if (kebabCase(input) === kebabCase(answers?.systemName)) { +export const duplicatedSystemName = ( + input: string, + answers: A | undefined, +) => { + if ( + answers?.systemName && + kebabCase(input) === kebabCase(answers?.systemName) + ) { return `System name "${input}" already exists`; } @@ -21,9 +29,9 @@ export const validateDuplicatedElements = (input: string) => { if (!workspaceInfo) return true; - const systemElements = getAllSystemElements(workspaceInfo).map((elm) => - pascalCase(removeSpaces(elm.name)), - ); + const systemElements = getAllSystemElements(workspaceInfo, { + includeDeploymentNodes: true, + }).map((elm) => pascalCase(removeSpaces(elm.name))); const elementName = pascalCase(removeSpaces(input)); if (systemElements.includes(elementName)) { return `Element with name "${elementName}" already exists.`; @@ -61,13 +69,16 @@ export const validateDuplicatedViews = return true; }; -export function chainValidators(...validators: Validator[]): Validator { - return async (input: unknown, answers?: Answers | undefined) => { - for await (const validator of validators) { - const validation = await validator?.(input, answers); - if (validation !== true) return validation ?? false; - } +export function chainValidators< + A extends Record = Record, +>(...validators: Validator[]): (answers?: A) => Validator { + return (answers = {} as A) => + async (input: string) => { + for await (const validator of validators) { + const validation = await validator?.(input, answers); + if (validation !== true) return validation ?? false; + } - return true; - }; + return true; + }; } diff --git a/package.json b/package.json index b5ffb7a..d2fa4d8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "build:dev": "bun build ./lib/main.ts --compile --watch --outfile ./dist/scfz", - "test:dev": "bun test --watch", + "test:dev": "bun test --watch --coverage", "test": "bun test", "test:ci": "bun test --coverage", "postinstall": "scripts/install-git-hooks.sh", @@ -16,10 +16,10 @@ }, "trustedDependencies": [".", "@biomejs/biome"], "dependencies": { + "@inquirer/prompts": "^5.1.2", "chalk": "^5.3.0", "change-case": "^5.4.4", "handlebars": "^4.7.8", - "inquirer": "9", "minimist": "^1.2.8", "yargs": "^17.7.2" }, @@ -27,6 +27,7 @@ "@biomejs/biome": "^1.8.3", "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", + "@inquirer/type": "^1.4.0", "@types/bun": "^1.1.6", "@types/inquirer": "^9.0.7", "@types/yargs": "^17.0.32",