From 03cb47dddf0ea7ae64b0c4a5266997bff0ec307a Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Tue, 18 Jun 2024 08:36:57 +0200 Subject: [PATCH 1/2] Add new fullstack template (react+express) and refactor templates to support options --- core/agents/architect.py | 127 ++++++++-- core/agents/convo.py | 16 +- core/agents/error_handler.py | 1 + core/agents/orchestrator.py | 4 +- core/agents/tech_lead.py | 58 +++-- core/cli/helpers.py | 2 + core/cli/main.py | 44 ++-- core/config/__init__.py | 1 + ...c2f_refactor_specification_template_to_.py | 36 +++ core/db/models/specification.py | 6 +- .../architect/configure_template.prompt | 10 + .../prompts/architect/select_templates.prompt | 29 +++ core/prompts/architect/technologies.prompt | 38 +-- core/prompts/code-monkey/iteration.prompt | 1 + core/templates/base.py | 140 +++++++++++ core/templates/example_project.py | 8 +- .../info/javascript_react/summary.tpl | 9 + .../info/node_express_mongoose/summary.tpl | 10 + core/templates/info/react_express/summary.tpl | 39 +++ core/templates/javascript_react.py | 47 ++-- core/templates/node_express_mongoose.py | 52 ++-- core/templates/react_express.py | 122 +++++++++ core/templates/registry.py | 106 +------- core/templates/render.py | 12 +- .../javascript_react/.eslintrc.cjs | 0 .../{tpl => tree}/javascript_react/.gitignore | 0 .../{tpl => tree}/javascript_react/index.html | 0 .../javascript_react/package.json | 0 .../javascript_react/public/.gitkeep | 0 .../javascript_react/src/App.css | 0 .../javascript_react/src/App.jsx | 0 .../javascript_react/src/assets/.gitkeep | 0 .../javascript_react/src/index.css | 0 .../javascript_react/src/main.jsx | 0 .../javascript_react/vite.config.js | 0 .../{tpl => tree}/node_express_mongoose/.env | 0 .../node_express_mongoose/.env.example | 0 .../node_express_mongoose/models/User.js | 0 .../node_express_mongoose/package.json | 0 .../public/css/style.css | 0 .../node_express_mongoose/public/js/main.js | 0 .../routes/authRoutes.js | 0 .../routes/middleware/authMiddleware.js | 0 .../node_express_mongoose/server.js | 0 .../node_express_mongoose/views/index.ejs | 0 .../node_express_mongoose/views/login.ejs | 0 .../views/partials/_footer.ejs | 0 .../views/partials/_head.ejs | 0 .../views/partials/_header.ejs | 0 .../node_express_mongoose/views/register.ejs | 0 core/templates/tree/react_express/.babelrc | 13 + core/templates/tree/react_express/.env | 44 ++++ .../tree/react_express/.eslintrc.json | 18 ++ core/templates/tree/react_express/.gitignore | 32 +++ core/templates/tree/react_express/README.md | 92 +++++++ core/templates/tree/react_express/api/app.js | 39 +++ .../api/middlewares/authMiddleware.js | 29 +++ .../api/middlewares/errorMiddleware.js | 22 ++ .../tree/react_express/api/models/init.js | 31 +++ .../tree/react_express/api/models/user.js | 79 ++++++ .../react_express/api/routes/authRoutes.js | 64 +++++ .../tree/react_express/api/routes/index.js | 8 + .../react_express/api/services/userService.js | 232 ++++++++++++++++++ .../tree/react_express/api/utils/log.js | 13 + .../tree/react_express/api/utils/mail.js | 33 +++ .../tree/react_express/api/utils/password.js | 38 +++ .../tree/react_express/components.json | 17 ++ core/templates/tree/react_express/index.html | 15 ++ .../tree/react_express/jsconfig.json | 10 + .../templates/tree/react_express/package.json | 77 ++++++ .../tree/react_express/postcss.config.js | 6 + .../tree/react_express/prisma/schema.prisma | 27 ++ .../tree/react_express/public/.gitkeep | 0 core/templates/tree/react_express/server.js | 34 +++ .../tree/react_express/tailwind.config.js | 76 ++++++ .../tree/react_express/tsconfig.json | 10 + .../tree/react_express/ui/assets/.gitkeep | 0 .../react_express/ui/components/ui/alert.jsx | 11 + .../react_express/ui/components/ui/button.jsx | 47 ++++ .../react_express/ui/components/ui/card.jsx | 50 ++++ .../react_express/ui/components/ui/input.jsx | 19 ++ .../react_express/ui/components/ui/label.jsx | 16 ++ .../templates/tree/react_express/ui/index.css | 82 +++++++ .../tree/react_express/ui/lib/utils.js | 6 + core/templates/tree/react_express/ui/main.jsx | 53 ++++ .../tree/react_express/ui/pages/Home.css | 6 + .../tree/react_express/ui/pages/Home.jsx | 11 + .../tree/react_express/ui/pages/Login.jsx | 98 ++++++++ .../tree/react_express/ui/pages/Register.jsx | 96 ++++++++ .../tree/react_express/vite.config.js | 21 ++ tests/agents/test_architect.py | 46 ++-- tests/agents/test_tech_lead.py | 2 +- tests/cli/test_cli.py | 1 + tests/templates/test_templates.py | 85 +++++-- 94 files changed, 2340 insertions(+), 287 deletions(-) create mode 100644 core/db/migrations/versions/08d71952ec2f_refactor_specification_template_to_.py create mode 100644 core/prompts/architect/configure_template.prompt create mode 100644 core/prompts/architect/select_templates.prompt create mode 100644 core/prompts/code-monkey/iteration.prompt create mode 100644 core/templates/base.py create mode 100644 core/templates/info/javascript_react/summary.tpl create mode 100644 core/templates/info/node_express_mongoose/summary.tpl create mode 100644 core/templates/info/react_express/summary.tpl create mode 100644 core/templates/react_express.py rename core/templates/{tpl => tree}/javascript_react/.eslintrc.cjs (100%) rename core/templates/{tpl => tree}/javascript_react/.gitignore (100%) rename core/templates/{tpl => tree}/javascript_react/index.html (100%) rename core/templates/{tpl => tree}/javascript_react/package.json (100%) rename core/templates/{tpl => tree}/javascript_react/public/.gitkeep (100%) rename core/templates/{tpl => tree}/javascript_react/src/App.css (100%) rename core/templates/{tpl => tree}/javascript_react/src/App.jsx (100%) rename core/templates/{tpl => tree}/javascript_react/src/assets/.gitkeep (100%) rename core/templates/{tpl => tree}/javascript_react/src/index.css (100%) rename core/templates/{tpl => tree}/javascript_react/src/main.jsx (100%) rename core/templates/{tpl => tree}/javascript_react/vite.config.js (100%) rename core/templates/{tpl => tree}/node_express_mongoose/.env (100%) rename core/templates/{tpl => tree}/node_express_mongoose/.env.example (100%) rename core/templates/{tpl => tree}/node_express_mongoose/models/User.js (100%) rename core/templates/{tpl => tree}/node_express_mongoose/package.json (100%) rename core/templates/{tpl => tree}/node_express_mongoose/public/css/style.css (100%) rename core/templates/{tpl => tree}/node_express_mongoose/public/js/main.js (100%) rename core/templates/{tpl => tree}/node_express_mongoose/routes/authRoutes.js (100%) rename core/templates/{tpl => tree}/node_express_mongoose/routes/middleware/authMiddleware.js (100%) rename core/templates/{tpl => tree}/node_express_mongoose/server.js (100%) rename core/templates/{tpl => tree}/node_express_mongoose/views/index.ejs (100%) rename core/templates/{tpl => tree}/node_express_mongoose/views/login.ejs (100%) rename core/templates/{tpl => tree}/node_express_mongoose/views/partials/_footer.ejs (100%) rename core/templates/{tpl => tree}/node_express_mongoose/views/partials/_head.ejs (100%) rename core/templates/{tpl => tree}/node_express_mongoose/views/partials/_header.ejs (100%) rename core/templates/{tpl => tree}/node_express_mongoose/views/register.ejs (100%) create mode 100644 core/templates/tree/react_express/.babelrc create mode 100644 core/templates/tree/react_express/.env create mode 100644 core/templates/tree/react_express/.eslintrc.json create mode 100644 core/templates/tree/react_express/.gitignore create mode 100644 core/templates/tree/react_express/README.md create mode 100644 core/templates/tree/react_express/api/app.js create mode 100644 core/templates/tree/react_express/api/middlewares/authMiddleware.js create mode 100644 core/templates/tree/react_express/api/middlewares/errorMiddleware.js create mode 100644 core/templates/tree/react_express/api/models/init.js create mode 100644 core/templates/tree/react_express/api/models/user.js create mode 100644 core/templates/tree/react_express/api/routes/authRoutes.js create mode 100644 core/templates/tree/react_express/api/routes/index.js create mode 100644 core/templates/tree/react_express/api/services/userService.js create mode 100644 core/templates/tree/react_express/api/utils/log.js create mode 100644 core/templates/tree/react_express/api/utils/mail.js create mode 100644 core/templates/tree/react_express/api/utils/password.js create mode 100644 core/templates/tree/react_express/components.json create mode 100644 core/templates/tree/react_express/index.html create mode 100644 core/templates/tree/react_express/jsconfig.json create mode 100644 core/templates/tree/react_express/package.json create mode 100644 core/templates/tree/react_express/postcss.config.js create mode 100644 core/templates/tree/react_express/prisma/schema.prisma create mode 100644 core/templates/tree/react_express/public/.gitkeep create mode 100644 core/templates/tree/react_express/server.js create mode 100644 core/templates/tree/react_express/tailwind.config.js create mode 100644 core/templates/tree/react_express/tsconfig.json create mode 100644 core/templates/tree/react_express/ui/assets/.gitkeep create mode 100644 core/templates/tree/react_express/ui/components/ui/alert.jsx create mode 100644 core/templates/tree/react_express/ui/components/ui/button.jsx create mode 100644 core/templates/tree/react_express/ui/components/ui/card.jsx create mode 100644 core/templates/tree/react_express/ui/components/ui/input.jsx create mode 100644 core/templates/tree/react_express/ui/components/ui/label.jsx create mode 100644 core/templates/tree/react_express/ui/index.css create mode 100644 core/templates/tree/react_express/ui/lib/utils.js create mode 100644 core/templates/tree/react_express/ui/main.jsx create mode 100644 core/templates/tree/react_express/ui/pages/Home.css create mode 100644 core/templates/tree/react_express/ui/pages/Home.jsx create mode 100644 core/templates/tree/react_express/ui/pages/Login.jsx create mode 100644 core/templates/tree/react_express/ui/pages/Register.jsx create mode 100644 core/templates/tree/react_express/vite.config.js diff --git a/core/agents/architect.py b/core/agents/architect.py index c1ce78d72..d841eac68 100644 --- a/core/agents/architect.py +++ b/core/agents/architect.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Optional from pydantic import BaseModel, Field @@ -9,8 +10,12 @@ from core.llm.parser import JSONParser from core.log import get_logger from core.telemetry import telemetry +from core.templates.base import BaseProjectTemplate, NoOptions from core.templates.example_project import EXAMPLE_PROJECTS -from core.templates.registry import PROJECT_TEMPLATES, ProjectTemplateEnum +from core.templates.registry import ( + PROJECT_TEMPLATES, + ProjectTemplateEnum, +) from core.ui.base import ProjectStage ARCHITECTURE_STEP_NAME = "Project architecture" @@ -21,6 +26,14 @@ log = get_logger(__name__) +class AppType(str, Enum): + WEB = "web-app" + API = "api-service" + MOBILE = "mobile-app" + DESKTOP = "desktop-app" + CLI = "cli-tool" + + # FIXME: all the reponse pydantic models should be strict (see config._StrictModel), also check if we # can disallow adding custom Python attributes to the model class SystemDependency(BaseModel): @@ -54,9 +67,9 @@ class PackageDependency(BaseModel): class Architecture(BaseModel): - architecture: str = Field( - None, - description="General description of the app architecture.", + app_type: AppType = Field( + AppType.WEB, + description="Type of the app to build.", ) system_dependencies: list[SystemDependency] = Field( None, @@ -66,9 +79,16 @@ class Architecture(BaseModel): None, description="List of framework/language-specific packages used by the app.", ) + + +class TemplateSelection(BaseModel): + architecture: str = Field( + None, + description="General description of the app architecture.", + ) template: Optional[ProjectTemplateEnum] = Field( None, - description="Project template to use for the app, if any (optional, can be null).", + description="Project template to use for the app, or null if no template is a good fit.", ) @@ -89,23 +109,70 @@ async def run(self) -> AgentResponse: await self.check_system_dependencies(spec) self.next_state.specification = spec - telemetry.set("template", spec.template) + telemetry.set("templates", spec.templates) self.next_state.action = ARCHITECTURE_STEP_NAME return AgentResponse.done(self) + async def select_templates(self, spec: Specification) -> dict[str, BaseProjectTemplate]: + """ + Select project template(s) to use based on the project description. + + Although the Pythagora database models support multiple projects, this + function will achoose at most one project template, as we currently don't + have templates that could be used together in a single project. + + :param spec: Project specification. + :return: Dictionary of selected project templates. + """ + await self.send_message("Selecting starter templates ...") + + llm = self.get_llm() + convo = ( + AgentConvo(self) + .template( + "select_templates", + templates=PROJECT_TEMPLATES, + ) + .require_schema(TemplateSelection) + ) + tpl: TemplateSelection = await llm(convo, parser=JSONParser(TemplateSelection)) + templates = {} + if tpl.template: + template_class = PROJECT_TEMPLATES.get(tpl.template) + if template_class: + options = await self.configure_template(spec, template_class) + templates[tpl.template] = template_class( + options, + self.state_manager, + self.process_manager, + ) + + return tpl.architecture, templates + async def plan_architecture(self, spec: Specification): await self.send_message("Planning project architecture ...") + architecture_description, templates = await self.select_templates(spec) + + await self.send_message("Picking technologies to use ...") llm = self.get_llm() - convo = AgentConvo(self).template("technologies", templates=PROJECT_TEMPLATES).require_schema(Architecture) + convo = ( + AgentConvo(self) + .template( + "technologies", + templates=templates, + architecture=architecture_description, + ) + .require_schema(Architecture) + ) arch: Architecture = await llm(convo, parser=JSONParser(Architecture)) await self.check_compatibility(arch) - spec.architecture = arch.architecture + spec.architecture = architecture_description + spec.templates = {t.name: t.options_dict for t in templates.values()} spec.system_dependencies = [d.model_dump() for d in arch.system_dependencies] spec.package_dependencies = [d.model_dump() for d in arch.package_dependencies] - spec.template = arch.template.value if arch.template else None async def check_compatibility(self, arch: Architecture) -> bool: warn_system_deps = [dep.name for dep in arch.system_dependencies if dep.name.lower() in WARN_SYSTEM_DEPS] @@ -113,7 +180,7 @@ async def check_compatibility(self, arch: Architecture) -> bool: if warn_system_deps: await self.ask_question( - f"Warning: GPT Pilot doesn't officially support {', '.join(warn_system_deps)}. " + f"Warning: Pythagora doesn't officially support {', '.join(warn_system_deps)}. " f"You can try to use {'it' if len(warn_system_deps) == 1 else 'them'}, but you may run into problems.", buttons={"continue": "Continue"}, buttons_only=True, @@ -122,7 +189,7 @@ async def check_compatibility(self, arch: Architecture) -> bool: if warn_package_deps: await self.ask_question( - f"Warning: GPT Pilot works best with vanilla JavaScript. " + f"Warning: Pythagora works best with vanilla JavaScript. " f"You can try try to use {', '.join(warn_package_deps)}, but you may run into problems. " f"Visit {WARN_FRAMEWORKS_URL} for more information.", buttons={"continue": "Continue"}, @@ -142,8 +209,8 @@ def prepare_example_project(self, spec: Specification): spec.architecture = arch["architecture"] spec.system_dependencies = arch["system_dependencies"] spec.package_dependencies = arch["package_dependencies"] - spec.template = arch["template"] - telemetry.set("template", spec.template) + spec.templates = arch["templates"] + telemetry.set("templates", spec.templates) async def check_system_dependencies(self, spec: Specification): """ @@ -157,6 +224,7 @@ async def check_system_dependencies(self, spec: Specification): deps = spec.system_dependencies for dep in deps: + await self.send_message(f"Checking if {dep['name']} is available ...") status_code, _, _ = await self.process_manager.run_command(dep["test"]) dep["installed"] = bool(status_code == 0) if status_code != 0: @@ -174,11 +242,30 @@ async def check_system_dependencies(self, spec: Specification): else: await self.send_message(f"✅ {dep['name']} is available.") - telemetry.set( - "architecture", - { - "description": spec.architecture, - "system_dependencies": deps, - "package_dependencies": spec.package_dependencies, - }, + async def configure_template(self, spec: Specification, template_class: BaseProjectTemplate) -> BaseModel: + """ + Ask the LLM to configure the template options. + + Based on the project description, the LLM should pick the options that + make the most sense. If template has no options, the method is a no-op + and returns an empty options model. + + :param spec: Project specification. + :param template_class: Template that needs to be configured. + :return: Configured options model. + """ + if template_class.options_class is NoOptions: + # If template has no options, no need to ask LLM for anything + return NoOptions() + + llm = self.get_llm() + convo = ( + AgentConvo(self) + .template( + "configure_template", + project_description=spec.description, + project_template=template_class, + ) + .require_schema(template_class.options_class) ) + return await llm(convo, parser=JSONParser(template_class.options_class)) diff --git a/core/agents/convo.py b/core/agents/convo.py index 9f18f316c..20b3ef1d6 100644 --- a/core/agents/convo.py +++ b/core/agents/convo.py @@ -3,6 +3,7 @@ from copy import deepcopy from typing import TYPE_CHECKING, Optional +import jsonref from pydantic import BaseModel from core.config import get_config @@ -88,6 +89,17 @@ def fork(self) -> "AgentConvo": return child def require_schema(self, model: BaseModel) -> "AgentConvo": - schema_txt = json.dumps(model.model_json_schema()) - self.user(f"IMPORTANT: Your response MUST conform to this JSON schema:\n```\n{schema_txt}\n```") + def remove_defs(d): + if isinstance(d, dict): + return {k: remove_defs(v) for k, v in d.items() if k != "$defs"} + elif isinstance(d, list): + return [remove_defs(v) for v in d] + else: + return d + + schema_txt = json.dumps(remove_defs(jsonref.loads(json.dumps(model.model_json_schema())))) + self.user( + f"IMPORTANT: Your response MUST conform to this JSON schema:\n```\n{schema_txt}\n```." + f"YOU MUST NEVER add any additional fields to your response, and NEVER add additional preamble like 'Here is your JSON'." + ) return self diff --git a/core/agents/error_handler.py b/core/agents/error_handler.py index 7a1d1b9ed..c24e9c8aa 100644 --- a/core/agents/error_handler.py +++ b/core/agents/error_handler.py @@ -106,6 +106,7 @@ async def handle_command_error(self, message: str, details: dict) -> AgentRespon { "id": uuid4().hex, "user_feedback": f"Error running command: {cmd}", + "user_feedback_qa": None, "description": llm_response, "alternative_solutions": [], "attempts": 1, diff --git a/core/agents/orchestrator.py b/core/agents/orchestrator.py index 62c069b8d..caf0b560d 100644 --- a/core/agents/orchestrator.py +++ b/core/agents/orchestrator.py @@ -194,9 +194,9 @@ def create_agent(self, prev_response: Optional[AgentResponse]) -> BaseAgent: elif ( not state.epics or not self.current_state.unfinished_tasks - or (state.specification.template and not state.files) + or (state.specification.templates and not state.files) ): - # Ask the Tech Lead to break down the initial project or feature into tasks and apply project template + # Ask the Tech Lead to break down the initial project or feature into tasks and apply project templates return TechLead(self.state_manager, self.ui, process_manager=self.process_manager) if state.current_task and state.docs is None and state.specification.complexity != Complexity.SIMPLE: diff --git a/core/agents/tech_lead.py b/core/agents/tech_lead.py index e4bf4f16d..3d9c954cc 100644 --- a/core/agents/tech_lead.py +++ b/core/agents/tech_lead.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import uuid4 from pydantic import BaseModel, Field @@ -12,7 +11,7 @@ from core.log import get_logger from core.telemetry import telemetry from core.templates.example_project import EXAMPLE_PROJECTS -from core.templates.registry import apply_project_template, get_template_description, get_template_summary +from core.templates.registry import PROJECT_TEMPLATES from core.ui.base import ProjectStage, success_source log = get_logger(__name__) @@ -51,9 +50,9 @@ async def run(self) -> AgentResponse: await self.ui.send_project_stage(ProjectStage.CODING) - if self.current_state.specification.template and not self.current_state.files: - await self.apply_project_template() - self.next_state.action = "Apply project template" + if self.current_state.specification.templates and not self.current_state.files: + await self.apply_project_templates() + self.next_state.action = "Apply project templates" return AgentResponse.done(self) if self.current_state.current_epic: @@ -77,25 +76,39 @@ def create_initial_project_epic(self): } ] - async def apply_project_template(self) -> Optional[str]: + async def apply_project_templates(self): state = self.current_state + summaries = [] + + # Only do this for the initial project and if the templates are specified + if len(state.epics) != 1 or not state.specification.templates: + return + + for template_name, template_options in state.specification.templates.items(): + template_class = PROJECT_TEMPLATES.get(template_name) + if not template_class: + log.error(f"Project template not found: {template_name}") + continue + + template = template_class( + template_options, + self.state_manager, + self.process_manager, + ) - # Only do this for the initial project and if the template is specified - if len(state.epics) != 1 or not state.specification.template: - return None - - description = get_template_description(state.specification.template) - log.info(f"Applying project template: {state.specification.template}") - await self.send_message(f"Applying project template {description} ...") - summary = await apply_project_template( - self.current_state.specification.template, - self.state_manager, - self.process_manager, - ) - # Saving template files will fill this in and we want it clear for the - # first task. + description = template.description + log.info(f"Applying project template: {template.name}") + await self.send_message(f"Applying project template {description} ...") + summary = await template.apply() + summaries.append(summary) + + # Saving template files will fill this in and we want it clear for the first task. self.next_state.relevant_files = None - return summary + + if summaries: + spec = self.current_state.specification.clone() + spec.description += "\n\n" + "\n\n".join(summaries) + self.next_state.specification = spec async def ask_for_new_feature(self) -> AgentResponse: if len(self.current_state.epics) > 2: @@ -140,7 +153,8 @@ async def plan_epic(self, epic) -> AgentResponse: "plan", epic=epic, task_type=self.current_state.current_epic.get("source", "app"), - existing_summary=get_template_summary(self.current_state.specification.template), + # FIXME: we're injecting summaries to initial description + existing_summary=None, ) .require_schema(DevelopmentPlan) ) diff --git a/core/cli/helpers.py b/core/cli/helpers.py index c2478f085..f2de37d9c 100644 --- a/core/cli/helpers.py +++ b/core/cli/helpers.py @@ -95,6 +95,7 @@ def parse_arguments() -> Namespace: --import-v0: Import data from a v0 (gpt-pilot) database with the given path --email: User's email address, if provided --extension-version: Version of the VSCode extension, if used + --no-check: Disable initial LLM API check :return: Parsed arguments object. """ version = get_version() @@ -134,6 +135,7 @@ def parse_arguments() -> Namespace: ) parser.add_argument("--email", help="User's email address", required=False) parser.add_argument("--extension-version", help="Version of the VSCode extension", required=False) + parser.add_argument("--no-check", help="Disable initial LLM API check", action="store_true") return parser.parse_args() diff --git a/core/cli/main.py b/core/cli/main.py index b591a6c51..204e790f2 100644 --- a/core/cli/main.py +++ b/core/cli/main.py @@ -110,19 +110,28 @@ async def start_new_project(sm: StateManager, ui: UIBase) -> bool: :param ui: User interface. :return: True if the project was created successfully, False otherwise. """ - try: - user_input = await ui.ask_question( - "What is the project name?", - allow_empty=False, - source=pythagora_source, - ) - except (KeyboardInterrupt, UIClosedError): - user_input = UserInput(cancelled=True) + while True: + try: + user_input = await ui.ask_question( + "What is the project name?", + allow_empty=False, + source=pythagora_source, + ) + except (KeyboardInterrupt, UIClosedError): + user_input = UserInput(cancelled=True) - if user_input.cancelled: - return False + if user_input.cancelled: + return False - project_state = await sm.create_project(user_input.text) + project_name = user_input.text.strip() + if not project_name: + await ui.send_message("Please choose a project name", source=pythagora_source) + elif len(project_name) > 100: + await ui.send_message("Please choose a shorter project name", source=pythagora_source) + else: + break + + project_state = await sm.create_project(project_name) return project_state is not None @@ -136,12 +145,13 @@ async def run_pythagora_session(sm: StateManager, ui: UIBase, args: Namespace): :return: True if the application ran successfully, False otherwise. """ - if not await llm_api_check(ui): - await ui.send_message( - "Pythagora cannot start because the LLM API is not reachable.", - source=pythagora_source, - ) - return False + if not args.no_check: + if not await llm_api_check(ui): + await ui.send_message( + "Pythagora cannot start because the LLM API is not reachable.", + source=pythagora_source, + ) + return False if args.project or args.branch or args.step: telemetry.set("is_continuation", True) diff --git a/core/config/__init__.py b/core/config/__init__.py index 7c3e37668..20152e0fb 100644 --- a/core/config/__init__.py +++ b/core/config/__init__.py @@ -28,6 +28,7 @@ "*.csv", "*.log", "go.sum", + "migration_lock.toml", ] IGNORE_SIZE_THRESHOLD = 50000 # 50K+ files are ignored by default diff --git a/core/db/migrations/versions/08d71952ec2f_refactor_specification_template_to_.py b/core/db/migrations/versions/08d71952ec2f_refactor_specification_template_to_.py new file mode 100644 index 000000000..655466bd0 --- /dev/null +++ b/core/db/migrations/versions/08d71952ec2f_refactor_specification_template_to_.py @@ -0,0 +1,36 @@ +"""refactor specification.template to specification.templates + +Revision ID: 08d71952ec2f +Revises: ff891d366761 +Create Date: 2024-06-14 18:23:09.070736 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "08d71952ec2f" +down_revision: Union[str, None] = "ff891d366761" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("specifications", schema=None) as batch_op: + batch_op.add_column(sa.Column("templates", sa.JSON(), nullable=True)) + batch_op.drop_column("template") + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("specifications", schema=None) as batch_op: + batch_op.add_column(sa.Column("template", sa.VARCHAR(), nullable=True)) + batch_op.drop_column("templates") + + # ### end Alembic commands ### diff --git a/core/db/models/specification.py b/core/db/models/specification.py index e711f3875..99eb54037 100644 --- a/core/db/models/specification.py +++ b/core/db/models/specification.py @@ -1,3 +1,4 @@ +from copy import deepcopy from typing import TYPE_CHECKING, Optional from sqlalchemy import delete, distinct, select @@ -29,7 +30,8 @@ class Specification(Base): architecture: Mapped[str] = mapped_column(default="") system_dependencies: Mapped[list[dict]] = mapped_column(default=list) package_dependencies: Mapped[list[dict]] = mapped_column(default=list) - template: Mapped[Optional[str]] = mapped_column() + templates: Mapped[Optional[dict]] = mapped_column() + complexity: Mapped[str] = mapped_column(server_default=Complexity.HARD) example_project: Mapped[Optional[str]] = mapped_column() @@ -45,7 +47,7 @@ def clone(self) -> "Specification": architecture=self.architecture, system_dependencies=self.system_dependencies, package_dependencies=self.package_dependencies, - template=self.template, + templates=deepcopy(self.templates) if self.templates else None, complexity=self.complexity, example_project=self.example_project, ) diff --git a/core/prompts/architect/configure_template.prompt b/core/prompts/architect/configure_template.prompt new file mode 100644 index 000000000..387151180 --- /dev/null +++ b/core/prompts/architect/configure_template.prompt @@ -0,0 +1,10 @@ +You're starting a new software project. The specification provided by the client is: + +``` +{{ project_description }} +``` + +Based on the specification, we've decided to use the following project scaffolding/template: {{ project_template.description }}. + +To start, we need to specify options for the project template: +{{ project_template.options_description }} diff --git a/core/prompts/architect/select_templates.prompt b/core/prompts/architect/select_templates.prompt new file mode 100644 index 000000000..9024cdbe0 --- /dev/null +++ b/core/prompts/architect/select_templates.prompt @@ -0,0 +1,29 @@ +You're designing the architecture and technical specifications for a new project. + +To speed up the project development, you need to consider if you should use a project template or start from scratch. If you decide to use a template, you should choose the one that best fits the project requirements. + +Here is a high level description of "{{ state.branch.project.name }}": +``` +{{ state.specification.description }} +``` + +You have an option to use project templates that implement standard boilerplate/scaffolding so you can start faster and be more productive. To be considered, a template must be compatible with the project requirements (it doesn't need to implement everything that will be used in the project, just a useful subset of needed technologies). You should pick one template that's the best match for this project. + +If no project templates are a good match, don't pick any! It's better to start from scratch than to use a template that is not a good fit for the project and then spend time reworking it to fit the requirements. + +Here are the available project templates: +{% for template in templates.values() %} +### {{ template.name }} ({{ template.stack }}) +{{ template.description }} + +Contains: +{{ template.summary }} +{% endfor %} + +Output your response in a valid JSON format like in this example: +```json +{ + "architecture": "Detailed description of the architecture of the application", + "template": "foo" // or null if you decide not to use a project template +} +``` diff --git a/core/prompts/architect/technologies.prompt b/core/prompts/architect/technologies.prompt index 6b9479ca5..73cbe2885 100644 --- a/core/prompts/architect/technologies.prompt +++ b/core/prompts/architect/technologies.prompt @@ -2,33 +2,19 @@ You're designing the architecture and technical specifications for a new project If the project requirements call out for specific technology, use that. Otherwise, if working on a web app, prefer Node.js for the backend (with Express if a web server is needed, and MongoDB if a database is needed), and Bootstrap for the front-end. You MUST NOT use Docker, Kubernetes, microservices and single-page app frameworks like React, Next.js, Angular, Vue or Svelte unless the project details explicitly require it. -Here are the details for the new project: ------------------------------ -{% include "partials/project_details.prompt" %} -{% include "partials/features_list.prompt" %} ------------------------------ - -Based on these details, think step by step to design the architecture for the project and choose technologies to use in building it. - -1. First, design and describe project architecture in general terms -2. Then, list any system dependencies that should be installed on the system prior to start of development. For each system depedency, output a {{ os }} command to check whether it's installed. -3. Finally, list any other 3rd party packages or libraries that will be used (that will be installed later using packager a package manager in the project repository/environment). -4. {% if templates %}Optionally, choose a project starter template.{% else %}(for this project there are no available starter/boilerplate templates, so there's no template to choose){% endif %} - -{% if templates %} -You have an option to use a project template that implements standard boilerplate/scaffolding so you can start faster and be more productive. To be considered, a template must be compatible with the architecture and technologies you've choosen (it doesn't need to implement everything that will be used in the project, just a useful subset). If multiple templates can be considered, pick one that's the best match. +Here is a high level description of "{{ state.branch.project.name }}": +``` +{{ state.specification.description }} +``` -If no project templates are a good match, don't pick any! It's better to start from scratch than to use a template that is not a good fit for the project and then spend time reworking it to fit the requirements. +Here is a short description of the project architecture: +{{ architecture }} -Here are the available project templates: -{% for name, tpl in templates.items() %} -### {{ name }} -{{ tpl.description }} +Based on these details, think step by step and choose technologies to use in building it. -Contains: -{{ tpl.summary }} -{% endfor %} -{% endif %} +1. First, list any system dependencies that should be installed on the system prior to start of development. For each system depedency, output a {{ os }} command to check whether it's installed. +2. Then, list any other 3rd party packages or libraries that will be used (that will be installed later using packager a package manager in the project repository/environment). +3. Finally, list the folder structure of the project, including any key files that should be included. *IMPORTANT*: You must follow these rules while creating your project: @@ -40,7 +26,6 @@ Contains: Output only your response in JSON format like in this example, without other commentary: ```json { - "architecture": "Detailed description of the architecture of the application", "system_dependencies": [ { "name": "Node.js", @@ -62,7 +47,6 @@ Output only your response in JSON format like in this example, without other com "description": "Express web server for Node" }, ... - ], - "template": "name of the project template to use" // or null if you decide not to use a project template + ] } ``` diff --git a/core/prompts/code-monkey/iteration.prompt b/core/prompts/code-monkey/iteration.prompt new file mode 100644 index 000000000..97d7d10c1 --- /dev/null +++ b/core/prompts/code-monkey/iteration.prompt @@ -0,0 +1 @@ +{% extends "troubleshooter/iteration.prompt" %} \ No newline at end of file diff --git a/core/templates/base.py b/core/templates/base.py new file mode 100644 index 000000000..4194b945f --- /dev/null +++ b/core/templates/base.py @@ -0,0 +1,140 @@ +from json import loads +from os.path import dirname, join +from typing import TYPE_CHECKING, Any, Optional, Type +from uuid import uuid4 + +from pydantic import BaseModel + +from core.log import get_logger +from core.templates.render import Renderer + +if TYPE_CHECKING: + from core.proc.process_manager import ProcessManager + from core.state.state_manager import StateManager + +log = get_logger(__name__) + + +class NoOptions(BaseModel): + """ + Options class for templates that do not require any options. + """ + + pass + + +class BaseProjectTemplate: + """ + Base project template, providing a common interface for all project templates. + """ + + name: str + path: str + description: str + options_class: Type[BaseModel] + options_description: str + file_descriptions: dict + + def __init__( + self, + options: BaseModel, + state_manager: "StateManager", + process_manager: "ProcessManager", + ): + """ + Create a new project template. + + :param options: The options to use for the template. + :param state_manager: The state manager instance to save files to. + :param process_manager: ProcessManager instance to run the install commands. + """ + if isinstance(options, dict): + options = self.options_class(**options) + + self.options = options + self.state_manager = state_manager + self.process_manager = process_manager + + self.file_renderer = Renderer(join(dirname(__file__), "tree")) + self.info_renderer = Renderer(join(dirname(__file__), "info")) + + def filter(self, path: str) -> Optional[str]: + """ + Filter a file path to be included in the rendered template. + + The method is called for every file in the template tree before rendering. + If the method returns None or an empty string, the file will be skipped. + Otherwise, the file will be rendered and stored under the file name + matching the provided filename. + + By default (base template), this function returns the path as-is. + + :param path: The file path to include or exclude. + :return: The path to use, or None if the file should be skipped. + """ + return path + + async def apply(self) -> Optional[str]: + """ + Apply a project template to a new project. + + :param template_name: The name of the template to apply. + :param state_manager: The state manager instance to save files to. + :param process_manager: The process manager instance to run install hooks with. + :return: A summary of the applied template, or None if no template was applied. + """ + state = self.state_manager.current_state + project_name = state.branch.project.name + project_folder = state.branch.project.folder_name + project_description = state.specification.description + + log.info(f"Applying project template {self.name} with options: {self.options_dict}") + + files = self.file_renderer.render_tree( + self.path, + { + "project_name": project_name, + "project_folder": project_folder, + "project_description": project_description, + "random_secret": uuid4().hex, + "options": self.options_dict, + }, + self.filter, + ) + + for file_name, file_content in files.items(): + desc = self.file_descriptions.get(file_name) + metadata = {"description": desc} if desc else None + await self.state_manager.save_file( + file_name, + file_content, + metadata=metadata, + from_template=True, + ) + + try: + await self.install_hook() + except Exception as err: + log.error( + f"Error running install hook for project template '{self.name}': {err}", + exc_info=True, + ) + + return self.info_renderer.render_template( + join(self.path, "summary.tpl"), + { + "description": self.description, + "options": self.options, + }, + ) + + async def install_hook(self): + """ + Command to run to complete the project scaffolding setup. + """ + raise NotImplementedError() + + @property + def options_dict(self) -> dict[str, Any]: + """Template options as a Python dictionary.""" + return loads(self.options.model_dump_json()) diff --git a/core/templates/example_project.py b/core/templates/example_project.py index 7cb47a6e3..efb6a6337 100644 --- a/core/templates/example_project.py +++ b/core/templates/example_project.py @@ -18,13 +18,13 @@ - Todos persist between sessions using the browser's local storage. The application saves any changes to the todo list (additions or state changes) in local storage and retrieves this data when the application is reloaded. Technical Specification: -- Platform/Technologies: The application is a web application developed using React. No backend technologies are required. +- Platform/Technologies: The application is a web application developed using React on frontend and Express on the backend, using SQLite as the database. - Styling: Use Bootstrap 5 for a simple and functional interface. Load Boostrap from the CDN (don't install it locally): - https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css - https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js - State Management: Directly in the React component - make sure to initialize the state from the local storage as default (... = useState(JSON.parse(localStorage.getItem('todos')) || []) to avoid race conditions -- Data Persistence: The application uses the browser's local storage to persist todos between sessions. It stores the array of todos as a JSON string and parses this data on application load. +- Data Persistence: Using the SQLite database on the backend via a REST API. """ EXAMPLE_PROJECT_ARCHITECTURE = { @@ -47,7 +47,9 @@ {"name": "react-dom", "description": "Serves as the entry point to the DOM and server renderers for React."}, {"name": "bootstrap", "description": "Frontend framework for developing responsive and mobile-first websites."}, ], - "template": "javascript_react", + "templates": { + "javascript_react": {}, + }, } EXAMPLE_PROJECT_PLAN = [ diff --git a/core/templates/info/javascript_react/summary.tpl b/core/templates/info/javascript_react/summary.tpl new file mode 100644 index 000000000..a651cd970 --- /dev/null +++ b/core/templates/info/javascript_react/summary.tpl @@ -0,0 +1,9 @@ +Here's what's already been implemented: + +* React web app using Vite devserver/bundler +* Initial setup with Vite for fast development +* Basic project structure for React development +* Development server setup for hot reloading +* Minimal configuration to get started with React +* Frontend-only, compatible with any backend stack + diff --git a/core/templates/info/node_express_mongoose/summary.tpl b/core/templates/info/node_express_mongoose/summary.tpl new file mode 100644 index 000000000..7e2839c35 --- /dev/null +++ b/core/templates/info/node_express_mongoose/summary.tpl @@ -0,0 +1,10 @@ +Here's what's already been implemented: + +* Node + Express + MongoDB web app with session-based authentication, EJS views and Bootstrap 5 +* initial Node + Express setup +* User model in Mongoose ORM with username and password fields, ensuring username is unique and hashing passwords with bcrypt prior to saving to the database +* session-based authentication using username + password (hashed using bcrypt) in routes/authRoutes.js, using express-session +* authentication middleware to protect routes that require login +* EJS view engine, html head, header and footer EJS partials, with included Boostrap 5.x CSS and JS +* routes and EJS views for login, register, and home (main) page +* config loading from environment using dotenv with a placeholder .env.example file: you will need to create a .env file with your own values diff --git a/core/templates/info/react_express/summary.tpl b/core/templates/info/react_express/summary.tpl new file mode 100644 index 000000000..efb8bbc49 --- /dev/null +++ b/core/templates/info/react_express/summary.tpl @@ -0,0 +1,39 @@ +Here's what's already been implemented: + +* {{ description }} +* Frontend: + * ReactJS based frontend in `ui/` folder using Vite devserver + * Integrated shadcn-ui component library with Tailwind CSS framework + * Client-side routing using `react-router-dom` with page components defined in `ui/pages/` and other components in `ui/components` + * Implememented pages: + * Home - home (index) page (`/`) + {% if options.auth %} + * Login - login page (`/login/`) - on login, stores the auth token to `token` variable in local storage + * Register - register page (`/register/`) + {% endif %} +* Backend: + * Express-based server implementing REST API endpoints in `api/` + {% if options.db_type == "sql" %} + * Relational (SQL) database support with Prisma ORM using SQLite as the database + {% elif options.db_type == "nosql" %} + * MongoDB database support with Mongoose + {% endif %} + {% if options.email %} + * Email sending support using Nodemailer + {% endif %} + {% if options.auth %} + * Token-based authentication (using opaque bearer tokens) + * User authentication (email + password): + * login/register API endpoints in `/api/routes/authRoutes.js` + * authorization middleware in `/api/middlewares/authMiddleware.js` + * user management logic in `/api/services/userService.js` + {% endif %} +* Development server: + * Vite devserver for frontend (`npm run dev:ui` to start the Vite dev server) + * Nodemon for backend (`npm run dev:api` to start Node.js server with Nodemon) + * Concurrently to run both servers together with a single command (`npm run dev`) - the preferred way to start the server in development +* Notes: + {% if options.db_type == "sql" %} + * Whenever a database model is changed or added in `schema.prisma`, remember to run `npx prisma format && npx prisma generate` to update the Prisma client + * For model relationships, remember to always also add the reverse relationship in `schema.prisma` at the same time, otherwise the database migration will fail + {% endif %} diff --git a/core/templates/javascript_react.py b/core/templates/javascript_react.py index 82c94eac2..2778032b7 100644 --- a/core/templates/javascript_react.py +++ b/core/templates/javascript_react.py @@ -1,28 +1,12 @@ -from core.proc.process_manager import ProcessManager +from .base import BaseProjectTemplate, NoOptions -async def install_hook(process_manager: ProcessManager): - """ - Command to run to complete the project scaffolding setup. - - :param process_manager: ProcessManager instance to run the install commands with. - """ - await process_manager.run_command("npm install") - - -JAVASCRIPT_REACT = { - "path": "javascript_react", - "description": "React web app using Vite devserver/bundler", - "summary": "\n".join( - [ - "* Initial setup with Vite for fast development", - "* Basic project structure for React development", - "* Development server setup for hot reloading", - "* Minimal configuration to get started with React", - ] - ), - "install_hook": install_hook, - "files": { +class JavascriptReactProjectTemplate(BaseProjectTemplate): + stack = "frontend" + name = "javascript_react" + path = "javascript_react" + description = "React web app using Vite devserver/bundler" + file_descriptions = { "vite.config.js": "Configuration file for Vite, a fast developer-friendly Javascript bundler/devserver.", "index.html": "Main entry point for the project. It includes a basic HTML structure with a root div element and a script tag importing a JavaScript file named main.jsx using the module type. References: src/main.jsx", ".eslintrc.cjs": "Configuration file for ESLint, a static code analysis tool for identifying problematic patterns found in JavaScript code. It defines rules for linting JavaScript code with a focus on React applications.", @@ -34,5 +18,18 @@ async def install_hook(process_manager: ProcessManager): "src/App.jsx": "Defines a functional component that serves as the root component in the project. The component is exported as the default export. References: src/App.css", "src/main.jsx": "Main entry point for a React application. It imports necessary modules, renders the main component 'App' inside a 'React.StrictMode' component, and mounts it to the root element in the HTML document. References: App.jsx, index.css", "src/assets/.gitkeep": "Empty file", - }, -} + } + summary = "\n".join( + [ + "* Initial setup with Vite for fast development", + "* Basic project structure for React development", + "* Development server setup for hot reloading", + "* Minimal configuration to get started with React", + "* Frontend-only, compatible with any backend stack", + ] + ) + options_class = NoOptions + options_description = "" + + async def install_hook(self): + await self.process_manager.run_command("npm install") diff --git a/core/templates/node_express_mongoose.py b/core/templates/node_express_mongoose.py index 6b691aa8e..50d83840c 100644 --- a/core/templates/node_express_mongoose.py +++ b/core/templates/node_express_mongoose.py @@ -1,31 +1,12 @@ -from core.proc.process_manager import ProcessManager +from .base import BaseProjectTemplate, NoOptions -async def install_hook(process_manager: ProcessManager): - """ - Command to run to complete the project scaffolding setup. - - :param process_manager: ProcessManager instance to run the install commands with. - """ - await process_manager.run_command("npm install") - - -NODE_EXPRESS_MONGOOSE = { - "path": "node_express_mongoose", - "description": "Node + Express + MongoDB web app with session-based authentication, EJS views and Bootstrap 5", - "summary": "\n".join( - [ - "* initial Node + Express setup", - "* User model in Mongoose ORM with username and password fields, ensuring username is unique and hashing passwords with bcrypt prior to saving to the database", - "* session-based authentication using username + password (hashed using bcrypt) in routes/authRoutes.js, using express-session", - "* authentication middleware to protect routes that require login", - "* EJS view engine, html head, header and footer EJS partials, with included Boostrap 5.x CSS and JS", - "* routes and EJS views for login, register, and home (main) page", - "* config loading from environment using dotenv with a placeholder .env.example file: you will need to create a .env file with your own values", - ] - ), - "install_hook": install_hook, - "files": { +class NodeExpressMongooseProjectTemplate(BaseProjectTemplate): + stack = "backend" + name = "node_express_mongoose" + path = "node_express_mongoose" + description = "Node + Express + MongoDB web app with session-based authentication, EJS views and Bootstrap 5" + file_descriptions = { ".env.example": "The .env.example file serves as a template for setting up environment variables used in the application. It provides placeholders for values such as the port number, MongoDB database URL, and session secret string.", ".env": "This file is a configuration file in the form of a .env file. It contains environment variables used by the application, such as the port to listen on, the MongoDB database URL, and the session secret string.", "server.js": "This `server.js` file sets up an Express server with MongoDB database connection, session management using connect-mongo, templating engine EJS, static file serving, authentication routes, error handling, and request logging. [References: dotenv, mongoose, express, express-session, connect-mongo, ./routes/authRoutes]", @@ -41,5 +22,20 @@ async def install_hook(process_manager: ProcessManager): "models/User.js": "This file defines a Mongoose model for a user with fields for username and password. It includes a pre-save hook to hash the user's password before saving it to the database using bcrypt. [References: mongoose, bcrypt]", "public/js/main.js": "The main.js file is a placeholder for future JavaScript code. It currently does not contain any specific functionality.", "public/css/style.css": "This file is a placeholder for custom styles. It does not contain any specific styles but is intended for adding custom CSS styles.", - }, -} + } + summary = "\n".join( + [ + "* initial Node + Express setup", + "* User model in Mongoose ORM with username and password fields, ensuring username is unique and hashing passwords with bcrypt prior to saving to the database", + "* session-based authentication using username + password (hashed using bcrypt) in routes/authRoutes.js, using express-session", + "* authentication middleware to protect routes that require login", + "* EJS view engine, html head, header and footer EJS partials, with included Boostrap 5.x CSS and JS", + "* routes and EJS views for login, register, and home (main) page", + "* config loading from environment using dotenv with a placeholder .env.example file: you will need to create a .env file with your own values", + ] + ) + options_class = NoOptions + options_description = "" + + async def install_hook(self): + await self.process_manager.run_command("npm install") diff --git a/core/templates/react_express.py b/core/templates/react_express.py new file mode 100644 index 000000000..ee51f479a --- /dev/null +++ b/core/templates/react_express.py @@ -0,0 +1,122 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + +from core.log import get_logger + +from .base import BaseProjectTemplate + +log = get_logger(__name__) + + +class DatabaseType(str, Enum): + SQL = "sql" + NOSQL = "nosql" + NONE = "none" + + +class TemplateOptions(BaseModel): + db_type: DatabaseType = Field( + DatabaseType.NONE, + description="Type of database to use in the project: relational/SQL (eg SQLite or Postgres), nosql (eg Mongo or Redis) or no database at all", + ) + auth: bool = Field( + description="Whether the app supports users and email/password authentication", + ) + + +TEMPLATE_OPTIONS = """ +* Database Type (`db_type`): What type of database should the project use: SQL (relational database like SQLite or Postgres), NoSQL (MongoDB, Redis), or no database at all. +* Authentication (`auth`): Does the project support users registering and authenticating (using email/password). +""" + + +class ReactExpressProjectTemplate(BaseProjectTemplate): + stack = "fullstack" + name = "react_express" + path = "react_express" + description = "React frontend with Node/Express REST API backend" + file_descriptions = { + ".babelrc": "Configuration file used by Babel, a JavaScript transpiler, to define presets for transforming code. In this specific file, two presets are defined: 'env' with a target of 'node' set to 'current', and 'jest' for Jest testing framework.", + ".env": "Contains environment variables used to configure the application. It specifies the Node environment, log level, port to listen on, database provider and URL, as well as the session secret string.", + ".eslintrc.json": "Contains ESLint configuration settings for the project. It specifies the environment (browser, ES2021, Node.js, Jest), extends the ESLint recommended rules, sets parser options for ECMAScript version 12 and module source type, and defines a custom rule to flag unused variables except for 'req', 'res', and 'next' parameters.", + ".gitignore": "Specifies patterns to exclude certain files and directories from being tracked by Git version control. It helps in preventing unnecessary files from being committed to the repository.", + "README.md": "Main README for a time-tracking web app for freelancers. The app uses React for the frontend, Node/Express for the backend, Prisma ORM, and SQLite database. It also utilizes Bootstrap for UI styling. The app allows users to register with email and password, uses opaque bearer tokens for authentication, and provides features like time tracking, saving time entries, viewing recent entries, generating reports, and exporting time entries in CSV format. The README also includes instructions for installation, development, testing, production deployment, and Docker usage.", + "api/app.js": "Sets up an Express app for handling API routes and serving a pre-built frontend. It enables CORS, parses JSON and URL-encoded data, serves static files, and defines routes for authentication and API endpoints. Additionally, it serves the pre-built frontend from the '../dist' folder for all other routes.", + "api/middlewares/authMiddleware.js": "Implements middleware functions for authentication and user authorization. The 'authenticateWithToken' function checks the Authorization header in the request, extracts the token, and authenticates the user using the UserService. The 'requireUser' function ensures that a user is present in the request object before allowing access to subsequent routes.", + "api/middlewares/errorMiddleware.js": "Implements middleware functions for handling 404 and 500 errors in an Express API. The 'handle404' function is responsible for returning a 404 response when a requested resource is not found or an unsupported HTTP method is used. The 'handleError' function is used to handle errors that occur within route handlers by logging the error details and sending a 500 response.", + "api/models/init.js": "Initializes the database client for interacting with the database.", + "api/models/user.js": "Defines a Mongoose schema for a user in a database, including fields like email, password, token, name, creation date, last login date, and account status. It also includes methods for authenticating users with password or token, setting and regenerating passwords, and custom JSON transformation. The file exports a Mongoose model named 'User' based on the defined schema.", + "api/routes/authRoutes.js": "Defines routes related to user authentication using Express.js. It includes endpoints for user login, registration, logout, and password management. The file imports services, middlewares, and utilities required for handling authentication logic.", + "api/routes/index.js": "Defines the API routes using the Express framework. It creates an instance of the Express Router and exports it to be used in the main application. The routes defined in this file are expected to have a '/api/' prefix to differentiate them from UI/frontend routes.", + "api/services/userService.js": "Implements a UserService class that provides various methods for interacting with user data in the database. It includes functions for listing users, getting a user by ID or email, updating user information, deleting users, authenticating users with password or token, creating new users, setting user passwords, and regenerating user tokens. The class utilizes the 'crypto' library for generating random UUIDs and imports functions from 'password.js' for password hashing and validation.", + "api/utils/log.js": "Defines a logger utility using the 'pino' library for logging purposes. It sets the log level based on the environment variable 'LOG_LEVEL' or defaults to 'info' in production and 'debug' in other environments. It validates the provided log level against the available levels in 'pino' and throws an error if an invalid level is specified. The logger function creates a new logger instance with the specified name and log level.", + "api/utils/mail.js": "Implements a utility function to send emails using nodemailer. It reads configuration options from environment variables and creates a nodemailer transporter with the specified options. The main function exported from this file is used to send emails by passing the necessary parameters like 'from', 'to', 'subject', and 'text'.", + "api/utils/password.js": "Implements functions related to password hashing and validation using the bcrypt algorithm. It provides functions to generate a password hash, validate a password against a hash, and check the format of a hash.", + "index.html": "The main entry point for the web application front-end. It defines the basic structure of an HTML document with a title and a root div element where the application content will be rendered. Additionally, it includes a script tag that imports the main.jsx file as a module, indicating that this file contains JavaScript code to be executed in a modular fashion.", + "package.json": "Configuration file used for both Node.js/Express backend and React/Vite frontend define metadata about the project such as name, version, description, dependencies, devDependencies, scripts, etc. It also specifies the entry point of the application through the 'main' field.", + "prisma/schema.prisma": "Defines the Prisma ORM schema for the project. It specifies the data source configuration, generator settings, and a 'User' model with various fields like id, email, password, token, name, createdAt, lastLoginAt, and isActive. It also includes index definitions for 'email' and 'token' fields.", + "public/.gitkeep": "(empty file)", + "server.js": "The main entry point for the backend. It sets up an HTTP server using Node.js's 'http' module, loads environment variables using 'dotenv', imports the main application logic from 'app.js', and initializes a logger from 'log.js'. It also handles uncaught exceptions and unhandled rejections, logging errors and closing the server accordingly. The main function starts the server on a specified port, defaulting to 3000 if not provided in the environment variables.", + "ui/assets/.gitkeep": "(empty file)", + "ui/index.css": "Defines main styling rules for the user interface elements. It sets the root font properties, body layout, and heading styles.", + "ui/main.jsx": "Responsible for setting up the main UI components of the application using React and React Router. It imports necessary dependencies like React, ReactDOM, and react-router-dom. It also imports the main CSS file for styling. The file defines the main router configuration for the app, setting up the Home page to be displayed at the root path. Finally, it renders the main UI components using ReactDOM.createRoot.", + "ui/pages/Home.css": "Defines the styling for the home page of the UI. It sets the maximum width of the root element to 1280px, centers it horizontally on the page, adds padding around it, and aligns the text in the center.", + "ui/pages/Home.jsx": "Defines a functional component named 'Home' that gets displayed on the app home page (`/`). It imports styles from the 'Home.css' file.", + "vite.config.js": "The 'vite.config.js' file is used to configure the Vite build tool for a project. In this specific file, the configuration is defined using the 'defineConfig' function provided by Vite. It includes the 'react' plugin from '@vitejs/plugin-react' to enable React support in the project. The configuration sets up the plugins array with the 'react' plugin initialized.", + } + + summary = "\n".join( + [ + "* React-based frontend using Vite devserver", + "* Radix/Shadcn UI components with Tailwind CSS, and React Router", + "* Node.js/Express REST API backend", + "* Dotenv-based configuration", + "* Database integration - optional (MongoDB via Mongoose or SQL/relational via Prisma)", + "* User authentication (email+password) - optional", + ] + ) + + options_class = TemplateOptions + options_description = TEMPLATE_OPTIONS.strip() + + async def install_hook(self): + await self.process_manager.run_command("npm install") + if self.options.db_type == DatabaseType.SQL: + await self.process_manager.run_command("npx prisma generate") + await self.process_manager.run_command("npx prisma migrate dev --name initial") + + def filter(self, path: str) -> Optional[str]: + if not self.options.auth and path in [ + "api/middlewares/authMiddleware.js", + "api/models/user.js", + "api/routes/authRoutes.js", + "api/services/userService.js", + "api/utils/password.js", + "ui/pages/Login.jsx", + "ui/pages/Register.jsx", + ]: + log.debug(f"Skipping {path} as auth is disabled") + return None + + if self.options.db_type != DatabaseType.SQL.value and path in [ + "prisma/schema.prisma", + ]: + log.debug(f"Skipping {path} as ORM is not Prisma") + return None + + if self.options.db_type != DatabaseType.NOSQL.value and path in [ + "api/models/user.js", + ]: + log.debug(f"Skipping {path} as Orm is not Mongoose") + return None + + if self.options.db_type == DatabaseType.NONE.value and path in [ + "api/models/init.js", + ]: + log.debug(f"Skipping {path} as database integration is not enabled") + return None + + log.debug(f"Including project template file {path}") + return path diff --git a/core/templates/registry.py b/core/templates/registry.py index 3fdbd26da..1e8f432f5 100644 --- a/core/templates/registry.py +++ b/core/templates/registry.py @@ -1,20 +1,10 @@ -import os from enum import Enum -from typing import Optional -from uuid import uuid4 from core.log import get_logger -from core.proc.process_manager import ProcessManager -from core.state.state_manager import StateManager -from .javascript_react import JAVASCRIPT_REACT -from .node_express_mongoose import NODE_EXPRESS_MONGOOSE -from .render import Renderer - -PROJECT_TEMPLATES = { - "node_express_mongoose": NODE_EXPRESS_MONGOOSE, - "javascript_react": JAVASCRIPT_REACT, -} +from .javascript_react import JavascriptReactProjectTemplate +from .node_express_mongoose import NodeExpressMongooseProjectTemplate +from .react_express import ReactExpressProjectTemplate log = get_logger(__name__) @@ -22,87 +12,13 @@ class ProjectTemplateEnum(str, Enum): """Choices of available project templates.""" - NODE_EXPRESS_MONGOOSE = "node_express_mongoose" - JAVASCRIPT_REACT = "javascript_react" - - -async def apply_project_template( - template_name: str, - state_manager: StateManager, - process_manager: ProcessManager, -) -> Optional[str]: - """ - Apply a project template to a new project. - - :param template_name: The name of the template to apply. - :param state_manager: The state manager instance to save files to. - :param process_manager: The process manager instance to run install hooks with. - :return: A summary of the applied template, or None if no template was applied. - """ - if not template_name or template_name not in PROJECT_TEMPLATES: - log.warning(f"Project template '{template_name}' not found, ignoring") - return None - - project_name = state_manager.current_state.branch.project.name - project_description = state_manager.current_state.specification.description - template = PROJECT_TEMPLATES[template_name] - install_hook = template.get("install_hook") - - # TODO: this could be configurable to get premium templates - r = Renderer(os.path.join(os.path.dirname(__file__), "tpl")) - - log.info(f"Applying project template {template_name}...") - - files = r.render_tree( - template["path"], - { - "project_name": project_name, - "project_description": project_description, - "random_secret": uuid4().hex, - }, - ) + JAVASCRIPT_REACT = JavascriptReactProjectTemplate.name + NODE_EXPRESS_MONGOOSE = NodeExpressMongooseProjectTemplate.name + REACT_EXPRESS = ReactExpressProjectTemplate.name - descriptions = template.get("files", {}) - for file_name, file_content in files.items(): - desc = descriptions.get(file_name) - metadata = {"description": desc} if desc else None - await state_manager.save_file(file_name, file_content, metadata=metadata, from_template=True) - try: - if install_hook: - await install_hook(process_manager) - except Exception as err: - log.error( - f"Error running install hook for project template '{template_name}': {err}", - exc_info=True, - ) - - return template["summary"] - - -def get_template_summary(template_name: str) -> Optional[str]: - """ - Get a summary of a project template. - - :param template_name: The name of the project template. - :return: A summary of the template, or None if no template was found. - """ - if not template_name or template_name not in PROJECT_TEMPLATES: - log.warning(f"Project template '{template_name}' not found, ignoring") - return None - template = PROJECT_TEMPLATES[template_name] - return template["summary"] - - -def get_template_description(template_name: str) -> Optional[str]: - """ - Get the description of a project template. - - :param template_name: The name of the project template. - :return: A summary of the template, or None if no template was found. - """ - if not template_name or template_name not in PROJECT_TEMPLATES: - log.warning(f"Project template '{template_name}' not found, ignoring") - return None - template = PROJECT_TEMPLATES[template_name] - return template["description"] +PROJECT_TEMPLATES = { + JavascriptReactProjectTemplate.name: JavascriptReactProjectTemplate, + NodeExpressMongooseProjectTemplate.name: NodeExpressMongooseProjectTemplate, + ReactExpressProjectTemplate.name: ReactExpressProjectTemplate, +} diff --git a/core/templates/render.py b/core/templates/render.py index e0f0a7d84..e999f8dba 100644 --- a/core/templates/render.py +++ b/core/templates/render.py @@ -8,6 +8,16 @@ from jinja2 import Environment, FileSystemLoader +def escape_string(str: str) -> str: + """ + Escape special characters in a string + + :param str: The string to escape + :return: The escaped string + """ + return str.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + class Renderer: """ Render a Jinja template @@ -40,7 +50,7 @@ def __init__(self, template_dir: str): keep_trailing_newline=True, ) # Add filters here - # self.jinja_env.filters["qstr"] = qstr + self.jinja_env.filters["escape_string"] = escape_string def render_template(self, template: str, context: Any) -> str: """ diff --git a/core/templates/tpl/javascript_react/.eslintrc.cjs b/core/templates/tree/javascript_react/.eslintrc.cjs similarity index 100% rename from core/templates/tpl/javascript_react/.eslintrc.cjs rename to core/templates/tree/javascript_react/.eslintrc.cjs diff --git a/core/templates/tpl/javascript_react/.gitignore b/core/templates/tree/javascript_react/.gitignore similarity index 100% rename from core/templates/tpl/javascript_react/.gitignore rename to core/templates/tree/javascript_react/.gitignore diff --git a/core/templates/tpl/javascript_react/index.html b/core/templates/tree/javascript_react/index.html similarity index 100% rename from core/templates/tpl/javascript_react/index.html rename to core/templates/tree/javascript_react/index.html diff --git a/core/templates/tpl/javascript_react/package.json b/core/templates/tree/javascript_react/package.json similarity index 100% rename from core/templates/tpl/javascript_react/package.json rename to core/templates/tree/javascript_react/package.json diff --git a/core/templates/tpl/javascript_react/public/.gitkeep b/core/templates/tree/javascript_react/public/.gitkeep similarity index 100% rename from core/templates/tpl/javascript_react/public/.gitkeep rename to core/templates/tree/javascript_react/public/.gitkeep diff --git a/core/templates/tpl/javascript_react/src/App.css b/core/templates/tree/javascript_react/src/App.css similarity index 100% rename from core/templates/tpl/javascript_react/src/App.css rename to core/templates/tree/javascript_react/src/App.css diff --git a/core/templates/tpl/javascript_react/src/App.jsx b/core/templates/tree/javascript_react/src/App.jsx similarity index 100% rename from core/templates/tpl/javascript_react/src/App.jsx rename to core/templates/tree/javascript_react/src/App.jsx diff --git a/core/templates/tpl/javascript_react/src/assets/.gitkeep b/core/templates/tree/javascript_react/src/assets/.gitkeep similarity index 100% rename from core/templates/tpl/javascript_react/src/assets/.gitkeep rename to core/templates/tree/javascript_react/src/assets/.gitkeep diff --git a/core/templates/tpl/javascript_react/src/index.css b/core/templates/tree/javascript_react/src/index.css similarity index 100% rename from core/templates/tpl/javascript_react/src/index.css rename to core/templates/tree/javascript_react/src/index.css diff --git a/core/templates/tpl/javascript_react/src/main.jsx b/core/templates/tree/javascript_react/src/main.jsx similarity index 100% rename from core/templates/tpl/javascript_react/src/main.jsx rename to core/templates/tree/javascript_react/src/main.jsx diff --git a/core/templates/tpl/javascript_react/vite.config.js b/core/templates/tree/javascript_react/vite.config.js similarity index 100% rename from core/templates/tpl/javascript_react/vite.config.js rename to core/templates/tree/javascript_react/vite.config.js diff --git a/core/templates/tpl/node_express_mongoose/.env b/core/templates/tree/node_express_mongoose/.env similarity index 100% rename from core/templates/tpl/node_express_mongoose/.env rename to core/templates/tree/node_express_mongoose/.env diff --git a/core/templates/tpl/node_express_mongoose/.env.example b/core/templates/tree/node_express_mongoose/.env.example similarity index 100% rename from core/templates/tpl/node_express_mongoose/.env.example rename to core/templates/tree/node_express_mongoose/.env.example diff --git a/core/templates/tpl/node_express_mongoose/models/User.js b/core/templates/tree/node_express_mongoose/models/User.js similarity index 100% rename from core/templates/tpl/node_express_mongoose/models/User.js rename to core/templates/tree/node_express_mongoose/models/User.js diff --git a/core/templates/tpl/node_express_mongoose/package.json b/core/templates/tree/node_express_mongoose/package.json similarity index 100% rename from core/templates/tpl/node_express_mongoose/package.json rename to core/templates/tree/node_express_mongoose/package.json diff --git a/core/templates/tpl/node_express_mongoose/public/css/style.css b/core/templates/tree/node_express_mongoose/public/css/style.css similarity index 100% rename from core/templates/tpl/node_express_mongoose/public/css/style.css rename to core/templates/tree/node_express_mongoose/public/css/style.css diff --git a/core/templates/tpl/node_express_mongoose/public/js/main.js b/core/templates/tree/node_express_mongoose/public/js/main.js similarity index 100% rename from core/templates/tpl/node_express_mongoose/public/js/main.js rename to core/templates/tree/node_express_mongoose/public/js/main.js diff --git a/core/templates/tpl/node_express_mongoose/routes/authRoutes.js b/core/templates/tree/node_express_mongoose/routes/authRoutes.js similarity index 100% rename from core/templates/tpl/node_express_mongoose/routes/authRoutes.js rename to core/templates/tree/node_express_mongoose/routes/authRoutes.js diff --git a/core/templates/tpl/node_express_mongoose/routes/middleware/authMiddleware.js b/core/templates/tree/node_express_mongoose/routes/middleware/authMiddleware.js similarity index 100% rename from core/templates/tpl/node_express_mongoose/routes/middleware/authMiddleware.js rename to core/templates/tree/node_express_mongoose/routes/middleware/authMiddleware.js diff --git a/core/templates/tpl/node_express_mongoose/server.js b/core/templates/tree/node_express_mongoose/server.js similarity index 100% rename from core/templates/tpl/node_express_mongoose/server.js rename to core/templates/tree/node_express_mongoose/server.js diff --git a/core/templates/tpl/node_express_mongoose/views/index.ejs b/core/templates/tree/node_express_mongoose/views/index.ejs similarity index 100% rename from core/templates/tpl/node_express_mongoose/views/index.ejs rename to core/templates/tree/node_express_mongoose/views/index.ejs diff --git a/core/templates/tpl/node_express_mongoose/views/login.ejs b/core/templates/tree/node_express_mongoose/views/login.ejs similarity index 100% rename from core/templates/tpl/node_express_mongoose/views/login.ejs rename to core/templates/tree/node_express_mongoose/views/login.ejs diff --git a/core/templates/tpl/node_express_mongoose/views/partials/_footer.ejs b/core/templates/tree/node_express_mongoose/views/partials/_footer.ejs similarity index 100% rename from core/templates/tpl/node_express_mongoose/views/partials/_footer.ejs rename to core/templates/tree/node_express_mongoose/views/partials/_footer.ejs diff --git a/core/templates/tpl/node_express_mongoose/views/partials/_head.ejs b/core/templates/tree/node_express_mongoose/views/partials/_head.ejs similarity index 100% rename from core/templates/tpl/node_express_mongoose/views/partials/_head.ejs rename to core/templates/tree/node_express_mongoose/views/partials/_head.ejs diff --git a/core/templates/tpl/node_express_mongoose/views/partials/_header.ejs b/core/templates/tree/node_express_mongoose/views/partials/_header.ejs similarity index 100% rename from core/templates/tpl/node_express_mongoose/views/partials/_header.ejs rename to core/templates/tree/node_express_mongoose/views/partials/_header.ejs diff --git a/core/templates/tpl/node_express_mongoose/views/register.ejs b/core/templates/tree/node_express_mongoose/views/register.ejs similarity index 100% rename from core/templates/tpl/node_express_mongoose/views/register.ejs rename to core/templates/tree/node_express_mongoose/views/register.ejs diff --git a/core/templates/tree/react_express/.babelrc b/core/templates/tree/react_express/.babelrc new file mode 100644 index 000000000..6d1e60a23 --- /dev/null +++ b/core/templates/tree/react_express/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "env", + { + "targets": { + "node": "current" + } + } + ], + "jest" + ] +} diff --git a/core/templates/tree/react_express/.env b/core/templates/tree/react_express/.env new file mode 100644 index 000000000..b50bd841b --- /dev/null +++ b/core/templates/tree/react_express/.env @@ -0,0 +1,44 @@ +# Node environment - "production" (default) or "development" +NODE_ENV=development + +# Log level to use, default "info" in production and "debug" in development +# See https://github.com/pinojs/pino +LOG_LEVEL=debug + +# Port to listen on, default 3000 +PORT=3000 + +{% if options.db_type == 'sql' %} +# See https://www.prisma.io/docs/reference/database-reference/connection-urls#format +# For PostgreSQL: +# DATABASE_PROVIDER=postgresql +# DATABASE_URL=postgresql://user:password@host/database +# Default is SQLite: +DATABASE_PROVIDER=sqlite +DATABASE_URL=file:./db.sqlite + +{% elif options.db_type == 'nosql' %} +# See https://mongoosejs.com/docs/connections.html +DATABASE_URL=mongodb://localhost/myDb + +{% endif %} +{% if options.email %} +# E-mail sending with nodemailer; see https://nodemailer.com/smtp/#general-options +# NODEMAILER_HOST= +# NODEMAILER_PORT=25 +# NODEMAILER_USER= +# NODEMAILER_PASS= +# NODEMAILER_SECURE=false + +{% endif %} +{% if options.bg_tasks %} +# URL pointing to Redis, default is redis://127.0.0.1:6379 (localhost) +# REDIS_URL= + +# Queue name for background tasks using bull +# BG_TASKS_QUEUE=bg-tasks + +{% endif %} +# Session secret string (must be unique to your server) +SESSION_SECRET={{ random_secret }} + diff --git a/core/templates/tree/react_express/.eslintrc.json b/core/templates/tree/react_express/.eslintrc.json new file mode 100644 index 000000000..289745866 --- /dev/null +++ b/core/templates/tree/react_express/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true, + "jest": true + }, + "extends": [ + "eslint:recommended" + ], + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module" + }, + "rules": { + "no-unused-vars": ["error", { "argsIgnorePattern": "(req|res|next)" }] + } +} diff --git a/core/templates/tree/react_express/.gitignore b/core/templates/tree/react_express/.gitignore new file mode 100644 index 000000000..199305c2a --- /dev/null +++ b/core/templates/tree/react_express/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# SQLite databases, data files +*.db +*.csv + +# Keep environment variables out of version control +.env + diff --git a/core/templates/tree/react_express/README.md b/core/templates/tree/react_express/README.md new file mode 100644 index 000000000..e4b23f1ce --- /dev/null +++ b/core/templates/tree/react_express/README.md @@ -0,0 +1,92 @@ +# {{ project_name }} + +{{ project_description }} + +## Quickstart + +1. Install required packages: + + ``` + npm install + ``` + +2. Update `.env` with your settings. + +{% if options.db_type == 'sql' %} +3. Create initial database migration: + + ``` + npx prisma migrate dev --name initial + ``` + + When run the first time, it will also install + `@prisma/client` and generate client code. + +4. Run the tests: +{% else %} +3. Run the tests: +{% endif %} + + ``` + npm run test + ``` + +## Development + +To run the server in development mode, with log pretty-printing and hot-reload: + +``` +npm run dev +``` + +To run the tests, run the `test` script (`npm run test`). ESLint is used for linting and its configuration is specified in `.eslintrc.json`. + +Code style is automatically formatted using `prettier`. To manually run prettier, use `npm run prettier`. Better yet, integrate your editor to run it on save. + +## Production + +To run the app in production, run: + +``` +npm start +``` + +Logs will be sent to the standard output in JSON format. +{% if options.bg_tasks %} + +## Background tasks with Bull + +A simple task queue is built using `bull` and backed by Redis. Tasks are defined and exported in `src/tasks.js`. Call proxies are created automatically and tasks can be queued with: + +``` +import { tasks } from "./src/utils/queue.js"; +const result = await tasks.someFunction(...); +``` + +To run the worker(s) that will execute the queued tasks, run: + +``` +npm run worker +``` +{% endif %} + +## Using Docker + +Build the docker image with: + + docker build -t {{ project_folder }} . + +The default command is to start the web server (gunicorn). Run the image with `-P` docker option to expose the internal port (3000) and check the exposed port with `docker ps`: + + docker run --env-file .env --P {{ project_folder }} + docker ps + +Make sure you provide the correct path to the env file (this example assumes it's located in the local directory). + +To run a custom command using the image (for example, starting the Node +shell): + + docker run --env-file .env {{ project_folder }} npm run shell + +For more information on the docker build process, see the included +`Dockerfile`. diff --git a/core/templates/tree/react_express/api/app.js b/core/templates/tree/react_express/api/app.js new file mode 100644 index 000000000..7bb10278b --- /dev/null +++ b/core/templates/tree/react_express/api/app.js @@ -0,0 +1,39 @@ +import path from 'path'; + +import cors from 'cors'; +import express from 'express'; + +{% if options.auth %} +import authRoutes from './routes/authRoutes.js'; +import { authenticateWithToken } from './middlewares/authMiddleware.js'; +{% endif %} +import apiRoutes from './routes/index.js'; + +// Set up Express app +const app = express(); + +// Pretty-print JSON responses +app.enable('json spaces'); +// We want to be consistent with URL paths, so we enable strict routing +app.enable('strict routing'); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(cors()); +{% if options.auth %} + +// Authentication routes +app.use(authRoutes); +app.use(authenticateWithToken); +{% endif %} + +app.use(apiRoutes); + +app.use(express.static(path.join(import.meta.dirname, "..", "dist"))); + +// Assume all other routes are frontend and serve pre-built frontend from ../dist/ folder +app.get(/.*/, async (req, res) => { + res.sendFile(path.join(import.meta.dirname, "..", "dist", "index.html")); +}); + +export default app; diff --git a/core/templates/tree/react_express/api/middlewares/authMiddleware.js b/core/templates/tree/react_express/api/middlewares/authMiddleware.js new file mode 100644 index 000000000..cba76c28c --- /dev/null +++ b/core/templates/tree/react_express/api/middlewares/authMiddleware.js @@ -0,0 +1,29 @@ +import UserService from '../services/userService.js'; + +export const authenticateWithToken = (req, res, next) => { + const authHeader = req.get('Authorization'); + if (authHeader) { + const m = authHeader.match(/^(Token|Bearer) (.+)/i); + if (m) { + UserService.authenticateWithToken(m[2]) + .then((user) => { + req.user = user; + next(); + }) + .catch((err) => { + next(err); + }); + return; + } + } + + next(); +}; + +export const requireUser = (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + next(); +}; diff --git a/core/templates/tree/react_express/api/middlewares/errorMiddleware.js b/core/templates/tree/react_express/api/middlewares/errorMiddleware.js new file mode 100644 index 000000000..844b0e37e --- /dev/null +++ b/core/templates/tree/react_express/api/middlewares/errorMiddleware.js @@ -0,0 +1,22 @@ +import logger from '../utils/log.js'; + +const log = logger('api:middleware'); + +/* 404 handler for the missing API endpoints + * Due to how Express works, we don't know if the URL or HTTP method is + * incorrect, so we return 404 in both cases. + */ +export const handle404 = (req, res, next) => { + const { method, originalUrl } = req; + log.info({ method, originalUrl }, `Unhandled API request ${method} ${originalUrl}`); + return res.status(404).json({ error: 'Resource not found or unsupported HTTP method' }); +}; + +/* 500 handler in case we have an error in one of our route handlers + */ +export const handleError = (error, req, res, next) => { + const { method, originalUrl } = req; + + log.error({ method, originalUrl, error }, `Error while handling ${method} ${originalUrl}`); + res.status(500).json({ error }); +}; diff --git a/core/templates/tree/react_express/api/models/init.js b/core/templates/tree/react_express/api/models/init.js new file mode 100644 index 000000000..28b8c8deb --- /dev/null +++ b/core/templates/tree/react_express/api/models/init.js @@ -0,0 +1,31 @@ +{% if options.db_type == 'nosql' %} +import mongoose from 'mongoose'; +import logger from '../utils/log.js'; + +const log = logger('models'); + +const dbInit = async (options = {}) => { + const mongoUrl = process.env.DATABASE_URL || 'mongodb://localhost/myDb'; + + try { + await mongoose.connect(mongoUrl, options); + log.debug(`Connected to MongoDB at ${mongoUrl}`); + } catch (err) { + log.fatal(`Error connecting to database ${mongoUrl}:`, err); + throw err; + } +}; + +export default dbInit; +{% endif %} +{% if options.db_type == 'sql' %} +import Prisma from '@prisma/client'; + +// PrismaClient is not available when testing +const { PrismaClient } = Prisma || {}; +const prisma = PrismaClient ? new PrismaClient() : {}; + +{% if options.auth %} +export const User = prisma.user; +{% endif %} +{% endif %} diff --git a/core/templates/tree/react_express/api/models/user.js b/core/templates/tree/react_express/api/models/user.js new file mode 100644 index 000000000..cb091ec9c --- /dev/null +++ b/core/templates/tree/react_express/api/models/user.js @@ -0,0 +1,79 @@ +import mongoose from 'mongoose'; +import { randomUUID } from 'crypto'; +import isEmail from 'validator/lib/isEmail.js'; + +import { generatePasswordHash, validatePassword, isPasswordHash } from '../utils/password.js'; + +const schema = new mongoose.Schema({ + email: { + type: String, + required: true, + index: true, + unique: true, + lowercase: true, + validate: { validator: isEmail, message: 'Invalid email' }, + }, + password: { + type: String, + required: true, + validate: { validator: isPasswordHash, message: 'Invalid password hash' }, + }, + token: { + type: String, + unique: true, + index: true, + default: () => randomUUID(), + }, + name: { + type: String, + }, + createdAt: { + type: Date, + default: Date.now, + immutable: true, + }, + lastLoginAt: { + type: Date, + default: Date.now, + }, + isActive: { + type: Boolean, + default: true, + }, +}, { + versionKey: false, +}); + +schema.set('toJSON', { + /* eslint-disable */ + transform: (doc, ret, options) => { + delete ret._id; + delete ret.password; + return ret; + }, + /* eslint-enable */ +}); + +schema.statics.authenticateWithPassword = async function authenticateWithPassword(email, password) { + const user = await this.findOne({ email }).exec(); + if (!user) return null; + + const passwordValid = await validatePassword(password, user.password); + if (!passwordValid) return null; + + user.lastLoginAt = Date.now(); + const updatedUser = await user.save(); + + return updatedUser; +}; + +schema.methods.regenerateToken = async function regenerateToken() { + this.token = randomUUID(); + if (!this.isNew) { + await this.save(); + } + return this; +}; + +const User = mongoose.model('User', schema); +export default User; diff --git a/core/templates/tree/react_express/api/routes/authRoutes.js b/core/templates/tree/react_express/api/routes/authRoutes.js new file mode 100644 index 000000000..a7320ea27 --- /dev/null +++ b/core/templates/tree/react_express/api/routes/authRoutes.js @@ -0,0 +1,64 @@ +import { Router } from 'express'; + +import UserService from '../services/userService.js'; +import { requireUser } from '../middlewares/authMiddleware.js'; +import logger from '../utils/log.js'; + +const log = logger('api/routes/authRoutes'); + +const router = Router(); + +router.post('/api/auth/login', async (req, res) => { + const sendError = msg => res.status(400).json({ error: msg }); + const { email, password } = req.body; + + if (!email || !password) { + return sendError('Email and password are required'); + } + + const user = await UserService.authenticateWithPassword(email, password); + + if (user) { + return res.json(user); + } else { + return sendError('Email or password is incorrect'); + + } +}); + +router.get('/api/auth/login', (req, res) => res.status(405).json({ error: 'Login with POST instead' })); + +router.post('/api/auth/register', async (req, res, next) => { + if (req.user) { + return res.json({ user: req.user }); + } + try { + const user = await UserService.createUser(req.body); + return res.status(201).json(user); + } catch (error) { + log.error('Error while registering user', error); + return res.status(400).json({ error }); + } +}); + +router.get('/api/auth/register', (req, res) => res.status(405).json({ error: 'Register with POST instead' })); + +router.all('/api/auth/logout', async (req, res) => { + if (req.user) { + await UserService.regenerateToken(req.user); + } + return res.status(204).send(); +}); + +router.post('/api/auth/password', requireUser, async (req, res) => { + const { password } = req.body; + + if (!password) { + return res.status(400).json({ error: 'Password is required' }); + } + + await UserService.setPassword(req.user, password); + res.status(204).send(); +}); + +export default router; diff --git a/core/templates/tree/react_express/api/routes/index.js b/core/templates/tree/react_express/api/routes/index.js new file mode 100644 index 000000000..39cc638e9 --- /dev/null +++ b/core/templates/tree/react_express/api/routes/index.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; + +const router = Router(); + +// Define API routes here +// All API routes must have /api/ prefix to avoid conflicts with the UI/frontend. + +export default router; \ No newline at end of file diff --git a/core/templates/tree/react_express/api/services/userService.js b/core/templates/tree/react_express/api/services/userService.js new file mode 100644 index 000000000..70c869d06 --- /dev/null +++ b/core/templates/tree/react_express/api/services/userService.js @@ -0,0 +1,232 @@ +import { randomUUID } from 'crypto'; + +{% set mongoose = options.db_type == 'nosql' %} +{% if mongoose %} +import User from '../models/user.js'; +{% else %} +import { User } from '../models/init.js'; +{% endif %} +import { generatePasswordHash, validatePassword } from '../utils/password.js'; + +class UserService { + static async list() { + try { +{% if mongoose %} + return User.find(); +{% else %} + const users = await User.findMany(); + return users.map((u) => ({ ...u, password: undefined })); +{% endif %} + } catch (err) { + throw `Database error while listing users: ${err}`; + } + } + + static async get(id) { + try { +{% if mongoose %} + return User.findOne({ _id: id }).exec(); +{% else %} + const user = await User.findUnique({ + where: { id }, + }); + + if (!user) return null; + + delete user.password; + return user; +{% endif %} + } catch (err) { + throw `Database error while getting the user by their ID: ${err}`; + } + } + + static async getByEmail(email) { + try { +{% if mongoose %} + return User.findOne({ email }).exec(); +{% else %} + const user = await User.findUnique({ + where: { email }, + }); + + if (!user) return null; + + delete user.password; + return user; +{% endif %} + } catch (err) { + throw `Database error while getting the user by their email: ${err}`; + } + } + + static async update(id, data) { + try { +{% if mongoose %} + return User.findOneAndUpdate({ _id: id }, data, { new: true, upsert: false }); +{% else %} + return User.update({ + where: { id }, + }, { + data, + }); +{% endif %} + } catch (err) { + throw `Database error while updating user ${id}: ${err}`; + } + } + + static async delete(id) { + try { +{% if mongoose %} + const result = await User.deleteOne({ _id: id }).exec(); + return (result.deletedCount === 1); +{% else %} + return User.delete({ + where: { id }, + }); +{% endif %} + } catch (err) { + throw `Database error while deleting user ${id}: ${err}`; + } + } + + static async authenticateWithPassword(email, password) { + if (!email) throw 'Email is required'; + if (!password) throw 'Password is required'; + + try { +{% if mongoose %} + const user = await User.findOne({email}).exec(); +{% else %} + const user = await User.findUnique({ + where: {email}, + }); +{% endif %} + if (!user) return null; + + const passwordValid = await validatePassword(password, user.password); + if (!passwordValid) return null; + +{% if mongoose %} + user.lastLoginAt = Date.now(); + const updatedUser = await user.save(); +{% else %} + user.lastLoginAt = new Date(); + const updatedUser = await User.update({ + where: { id: user.id }, + data: { lastLoginAt: user.lastLoginAt }, + }); + + delete updatedUser.password; +{% endif %} + return updatedUser; + } catch (err) { + throw `Database error while authenticating user ${email} with password: ${err}`; + } + } + + static async authenticateWithToken(token) { + try { +{% if mongoose %} + return User.findOne({ token }).exec(); +{% else %} + const user = await User.findUnique({ + where: { token }, + }); + if (!user) return null; + + delete user.password; + return user; +{% endif %} + } catch (err) { + throw `Database error while authenticating user ${email} with token: ${err}`; + } + } + + static async createUser({ email, password, name = '' }) { + if (!email) throw 'Email is required'; + if (!password) throw 'Password is required'; + + const existingUser = await UserService.getByEmail(email); + if (existingUser) throw 'User with this email already exists'; + + const hash = await generatePasswordHash(password); + + try { +{% if mongoose %} + const user = new User({ + email, + password: hash, + name, + token: randomUUID(), + }); + + await user.save(); +{% else %} + const data = { + email, + password: hash, + name, + token: randomUUID(), + }; + + const user = await User.create({ data }); + + delete user.password; +{% endif %} + return user; + } catch (err) { + throw `Database error while creating new user: ${err}`; + } + } + + static async setPassword(user, password) { + if (!password) throw 'Password is required'; + user.password = await generatePasswordHash(password); // eslint-disable-line + + try { +{% if mongoose %} + if (!user.isNew) { + await user.save(); + } +{% else %} + if (user.id) { + return User.update({ + where: { id: user.id }, + data: { password: user.password }, + }); + } +{% endif %} + + return user; + } catch (err) { + throw `Database error while setting user password: ${err}`; + } + } + + static async regenerateToken(user) { + user.token = randomUUID(); // eslint-disable-line + + try { +{% if mongoose %} + if (!user.isNew) { + await user.save(); + } +{% else %} + if (user.id) { + return User.update({ + where: { id: user.id }, + data: { password: user.password }, + }); + } +{% endif %} + + return user; + } catch (err) { + throw `Database error while generating user token: ${err}`; + } + } +} + +export default UserService; diff --git a/core/templates/tree/react_express/api/utils/log.js b/core/templates/tree/react_express/api/utils/log.js new file mode 100644 index 000000000..464e65079 --- /dev/null +++ b/core/templates/tree/react_express/api/utils/log.js @@ -0,0 +1,13 @@ +import pino from 'pino'; + +const DEFAULT_LOG_LEVEL = process.env.NODE_ENV === "production" ? "info" : "debug"; +const level = process.env.LOG_LEVEL || DEFAULT_LOG_LEVEL; + +if (!pino.levels.values[level]) { + const validLevels = Object.keys(pino.levels.values).join(', '); + throw new Error(`Log level must be one of: ${validLevels}`); +} + +const logger = (name) => pino({ name, level }); + +export default logger; diff --git a/core/templates/tree/react_express/api/utils/mail.js b/core/templates/tree/react_express/api/utils/mail.js new file mode 100644 index 000000000..611b1ed2b --- /dev/null +++ b/core/templates/tree/react_express/api/utils/mail.js @@ -0,0 +1,33 @@ +/* Send mail using nodemailer + * + * Configure using NODEMAILER_* env variables. + * See https://nodemailer.com/smtp/ for all options + * + * Send mail with: + * + * import transport from "./src/utils/mail.js"; + * await transport.sendMail({ from, to, subject, text }); + * + * For all message options, see: https://nodemailer.com/message/ + */ +import nodemailer from "nodemailer"; + +import config from "./config.js"; + +const options = { + host: config.NODEMAILER_HOST, + port: config.NODEMAILER_PORT, + secure: config.NODEMAILER_SECURE, +}; + +if (config.NODEMAILER_USER && config.NODMAILER_PASS) { + options.auth = { + user: config.NODEMAILER_USER, + pass: config.NODEMAILER_PASS, + }; +} + +const transporter = nodemailer.createTransport(options); +const sendMail = transporter.sendMail.bind(transporter); + +export default sendMail; diff --git a/core/templates/tree/react_express/api/utils/password.js b/core/templates/tree/react_express/api/utils/password.js new file mode 100644 index 000000000..a38bd4a13 --- /dev/null +++ b/core/templates/tree/react_express/api/utils/password.js @@ -0,0 +1,38 @@ +import bcrypt from 'bcrypt'; + +/** + * Hashes the password using bcrypt algorithm + * @param {string} password - The password to hash + * @return {string} Password hash + */ +export const generatePasswordHash = async (password) => { + const salt = await bcrypt.genSalt(); + const hash = await bcrypt.hash(password, salt); + return hash; +}; + +/** + * Validates the password against the hash + * @param {string} password - The password to verify + * @param {string} hash - Password hash to verify against + * @return {boolean} True if the password matches the hash, false otherwise + */ +export const validatePassword = async (password, hash) => { + const result = await bcrypt.compare(password, hash); + return result; +}; + +/** + * Checks that the hash has a valid format + * @param {string} hash - Hash to check format for + * @return {boolean} True if passed string seems like valid hash, false otherwise + */ +export const isPasswordHash = (hash) => { + if (!hash || hash.length !== 60) return false; + try { + bcrypt.getRounds(hash); + return true; + } catch { + return false; + } +}; diff --git a/core/templates/tree/react_express/components.json b/core/templates/tree/react_express/components.json new file mode 100644 index 000000000..b887755a3 --- /dev/null +++ b/core/templates/tree/react_express/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "ui/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/core/templates/tree/react_express/index.html b/core/templates/tree/react_express/index.html new file mode 100644 index 000000000..23d6c3f21 --- /dev/null +++ b/core/templates/tree/react_express/index.html @@ -0,0 +1,15 @@ + + + + + + + {{ project_name }} + + + +
+ + + + \ No newline at end of file diff --git a/core/templates/tree/react_express/jsconfig.json b/core/templates/tree/react_express/jsconfig.json new file mode 100644 index 000000000..dc1da5684 --- /dev/null +++ b/core/templates/tree/react_express/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": [ + "./ui/*" + ] + } + } +} diff --git a/core/templates/tree/react_express/package.json b/core/templates/tree/react_express/package.json new file mode 100644 index 000000000..d331f3015 --- /dev/null +++ b/core/templates/tree/react_express/package.json @@ -0,0 +1,77 @@ +{ + "name": "{{ folder_name }}", + "type": "module", + "version": "0.0.1", + "description": "{{ project_name|escape_string}}", + "main": "server.js", + "author": "", + "license": "UNLICENSED", + "dependencies": { + "axios": "^1.7.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "bcrypt": "*", + "bull": "*", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cors": "*", + "dotenv": "*", + "express": "*", + "jsonschema": "*", +{% if options.db_type == 'nosql' %} + "mongoose": "*", + "validator": "*", +{% endif %} + "lucide-react": "^0.395.0", + "nodemailer": "*", + "pino": "*", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@prisma/client": "*", + "@types/node": "^20.14.6", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "babel": "*", + "babel-preset-env": "*", + "babel-preset-jest": "*", + "concurrently": "*", + "eslint": "*", + "eslint-config-airbnb-base": "*", + "eslint-plugin-import": "*", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "jest": "*", + "nodemon": "*", + "pino-pretty": "*", + "postcss": "^8.4.38", + "prettier": "*", +{% if options.db_type == 'sql' %} + "prisma": "*", +{% endif %} + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "*", + "supertest": "*", + "tailwindcss": "^3.4.4", + "vite": "^5.1.6" + }, + "scripts": { + "start:api": "node server.js", + "dev:api": "nodemon -w api -w .env -w server.js server | pino-pretty -clt -i 'hostname,pid'", + "lint:api": "eslint .", + "prettier:api": "prettier -w .", + "test:api": "jest --roots test --verbose", + "coverage:api": "jest --roots test --verbose --coverage", + "watch-test:api": "jest --roots test --verbose --watch", + "dev:ui": "vite", + "build:ui": "vite build", + "lint:ui": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview:ui": "vite preview", + "dev": "concurrently -n api,ui \"npm:dev:api\" \"npm:dev:ui\"" + } +} diff --git a/core/templates/tree/react_express/postcss.config.js b/core/templates/tree/react_express/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/core/templates/tree/react_express/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/core/templates/tree/react_express/prisma/schema.prisma b/core/templates/tree/react_express/prisma/schema.prisma new file mode 100644 index 000000000..b728cda4d --- /dev/null +++ b/core/templates/tree/react_express/prisma/schema.prisma @@ -0,0 +1,27 @@ +// Prisma schema file +// See https://www.prisma.io/docs/concepts/components/prisma-schema + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} +{% if options.auth %} + +model User { + id Int @id @default(autoincrement()) + email String @unique + password String + token String @unique + name String + createdAt DateTime @default(now()) + lastLoginAt DateTime @default(now()) + isActive Boolean @default(true) + + @@index([email]) + @@index([token]) +} +{% endif %} diff --git a/core/templates/tree/react_express/public/.gitkeep b/core/templates/tree/react_express/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/core/templates/tree/react_express/server.js b/core/templates/tree/react_express/server.js new file mode 100644 index 000000000..33d9d2635 --- /dev/null +++ b/core/templates/tree/react_express/server.js @@ -0,0 +1,34 @@ +import http from 'http'; +import dotenv from 'dotenv'; + +dotenv.config(); + +import app from './api/app.js'; +import logger from './api/utils/log.js'; +{% if options.db_type == 'nosql' %} +import mongoInit from './api/models/init.js'; +{% endif %} + +const log = logger('server'); +const server = http.createServer(app); + +process.on('uncaughtException', (err) => { + log.fatal({ err }, `Unhandled error ${err}`); + server.close(); +}); + +process.on('unhandledRejection', (reason) => { + log.error(`Unhandled error (in promise): ${reason}`); +}); + +// Main entry point to the application +const main = async () => { + {% if options.db_type == 'nosql' %} + await mongoInit(); + {% endif %} + const port = parseInt(process.env.PORT) || 3000; + log.info(`Listening on http://localhost:${port}/`); + await server.listen(port); +}; + +main(); diff --git a/core/templates/tree/react_express/tailwind.config.js b/core/templates/tree/react_express/tailwind.config.js new file mode 100644 index 000000000..247f47ee4 --- /dev/null +++ b/core/templates/tree/react_express/tailwind.config.js @@ -0,0 +1,76 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + './ui/main.jsx', + './ui/pages/**/*.{js,jsx}', + './ui/components/**/*.{js,jsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} diff --git a/core/templates/tree/react_express/tsconfig.json b/core/templates/tree/react_express/tsconfig.json new file mode 100644 index 000000000..dc1da5684 --- /dev/null +++ b/core/templates/tree/react_express/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": [ + "./ui/*" + ] + } + } +} diff --git a/core/templates/tree/react_express/ui/assets/.gitkeep b/core/templates/tree/react_express/ui/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/core/templates/tree/react_express/ui/components/ui/alert.jsx b/core/templates/tree/react_express/ui/components/ui/alert.jsx new file mode 100644 index 000000000..a9fae7cf2 --- /dev/null +++ b/core/templates/tree/react_express/ui/components/ui/alert.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { AlertCircle } from 'lucide-react'; + +export function AlertDestructive({ title, description }) { + return ( +
+

{title}

+

{description}

+
+ ); +} \ No newline at end of file diff --git a/core/templates/tree/react_express/ui/components/ui/button.jsx b/core/templates/tree/react_express/ui/components/ui/button.jsx new file mode 100644 index 000000000..9bdf65b89 --- /dev/null +++ b/core/templates/tree/react_express/ui/components/ui/button.jsx @@ -0,0 +1,47 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + () + ); +}) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/core/templates/tree/react_express/ui/components/ui/card.jsx b/core/templates/tree/react_express/ui/components/ui/card.jsx new file mode 100644 index 000000000..dd79b5133 --- /dev/null +++ b/core/templates/tree/react_express/ui/components/ui/card.jsx @@ -0,0 +1,50 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/core/templates/tree/react_express/ui/components/ui/input.jsx b/core/templates/tree/react_express/ui/components/ui/input.jsx new file mode 100644 index 000000000..c74d919d1 --- /dev/null +++ b/core/templates/tree/react_express/ui/components/ui/input.jsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + () + ); +}) +Input.displayName = "Input" + +export { Input } diff --git a/core/templates/tree/react_express/ui/components/ui/label.jsx b/core/templates/tree/react_express/ui/components/ui/label.jsx new file mode 100644 index 000000000..a1f40999c --- /dev/null +++ b/core/templates/tree/react_express/ui/components/ui/label.jsx @@ -0,0 +1,16 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/core/templates/tree/react_express/ui/index.css b/core/templates/tree/react_express/ui/index.css new file mode 100644 index 000000000..42fee7d1f --- /dev/null +++ b/core/templates/tree/react_express/ui/index.css @@ -0,0 +1,82 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 47.4% 11.2%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 100% 50%; + --destructive-foreground: 210 40% 98%; + + --ring: 215 20.2% 65.1%; + + --radius: 0.5rem; + } + + .dark { + --background: 224 71% 4%; + --foreground: 213 31% 91%; + + --muted: 223 47% 11%; + --muted-foreground: 215.4 16.3% 56.9%; + + --accent: 216 34% 17%; + --accent-foreground: 210 40% 98%; + + --popover: 224 71% 4%; + --popover-foreground: 215 20.2% 65.1%; + + --border: 216 34% 17%; + --input: 216 34% 17%; + + --card: 224 71% 4%; + --card-foreground: 213 31% 91%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 1.2%; + + --secondary: 222.2 47.4% 11.2%; + --secondary-foreground: 210 40% 98%; + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + + --ring: 216 34% 17%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + diff --git a/core/templates/tree/react_express/ui/lib/utils.js b/core/templates/tree/react_express/ui/lib/utils.js new file mode 100644 index 000000000..20aa6031c --- /dev/null +++ b/core/templates/tree/react_express/ui/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs) { + return twMerge(clsx(inputs)) +} diff --git a/core/templates/tree/react_express/ui/main.jsx b/core/templates/tree/react_express/ui/main.jsx new file mode 100644 index 000000000..874e4133a --- /dev/null +++ b/core/templates/tree/react_express/ui/main.jsx @@ -0,0 +1,53 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { createBrowserRouter, RouterProvider, useLocation } from "react-router-dom" + +import './index.css' + +// Pages in the app +import Home from './pages/Home.jsx' +{% if options.auth %} +import Register from './pages/Register.jsx' +import Login from './pages/Login.jsx' +{% endif %} + +function PageNotFound() { + const { pathname } = useLocation() + return ( +
+

Page Not Found

+

+ Page {pathname} does not exist. +
+ Go home +

+
+ ); +} + +const router = createBrowserRouter([ + { + path: "/", + element: , + }, +{% if options.auth %} + { + path: "/register/", + element: , + }, + { + path: "/login/", + element: , + }, +{% endif %} + { + path: "*", + element: , + } +]) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/core/templates/tree/react_express/ui/pages/Home.css b/core/templates/tree/react_express/ui/pages/Home.css new file mode 100644 index 000000000..f5b6dc4ac --- /dev/null +++ b/core/templates/tree/react_express/ui/pages/Home.css @@ -0,0 +1,6 @@ +#homePage { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} \ No newline at end of file diff --git a/core/templates/tree/react_express/ui/pages/Home.jsx b/core/templates/tree/react_express/ui/pages/Home.jsx new file mode 100644 index 000000000..3f9daa143 --- /dev/null +++ b/core/templates/tree/react_express/ui/pages/Home.jsx @@ -0,0 +1,11 @@ +import './Home.css' + +function Home() { + return ( +
+

{{ project_name }}

+
+ ) +} + +export default Home diff --git a/core/templates/tree/react_express/ui/pages/Login.jsx b/core/templates/tree/react_express/ui/pages/Login.jsx new file mode 100644 index 000000000..f210dcff1 --- /dev/null +++ b/core/templates/tree/react_express/ui/pages/Login.jsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import { AlertDestructive } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setLoading(''); + try { + const response = await axios.post('/api/auth/login', { email, password }); + localStorage.setItem('token', response.data.token); // Save token to local storage + navigate('/'); // Redirect to Home + } catch (error) { + console.error('Login error:', error); + setError(error.response?.data?.error || 'An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + return ( +
+ + {error && } + + Login + + Enter your email below to login to your account + + + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+
+ + {false && + Forgot your password? + } +
+ setPassword(e.target.value)} + required + /> +
+ +
+
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/core/templates/tree/react_express/ui/pages/Register.jsx b/core/templates/tree/react_express/ui/pages/Register.jsx new file mode 100644 index 000000000..51e408c7f --- /dev/null +++ b/core/templates/tree/react_express/ui/pages/Register.jsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import { AlertDestructive } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function Register() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + const response = await axios.post('/api/auth/register', { email, password }); + if (response.data) { + navigate('/login'); + } + } catch (error) { + console.error('Registration error:', error); + setError(error.response?.data?.error || 'An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + return ( +
+ + {error && } +
+ + Sign Up + + Enter your information to create an account + + + +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ +
+
+ Already have an account?{" "} + + Sign in + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/core/templates/tree/react_express/vite.config.js b/core/templates/tree/react_express/vite.config.js new file mode 100644 index 000000000..80d5d2008 --- /dev/null +++ b/core/templates/tree/react_express/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./ui"), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + } + } + } +}) diff --git a/tests/agents/test_architect.py b/tests/agents/test_architect.py index 37b74cd15..545d7a375 100644 --- a/tests/agents/test_architect.py +++ b/tests/agents/test_architect.py @@ -2,7 +2,7 @@ import pytest -from core.agents.architect import Architect, Architecture, PackageDependency, SystemDependency +from core.agents.architect import Architect, Architecture, PackageDependency, SystemDependency, TemplateSelection from core.agents.response import ResponseType from core.ui.base import UserInput @@ -16,28 +16,32 @@ async def test_run(agentcontext): arch = Architect(sm, ui, process_manager=pm) arch.get_llm = mock_get_llm( - return_value=Architecture( - architecture="dummy arch", - system_dependencies=[ - SystemDependency( - name="docker", - description="Docker is a containerization platform.", - test="docker --version", - required_locally=True, - ) - ], - package_dependencies=[ - PackageDependency( - name="express", - description="Express is a Node.js framework.", - ) - ], - template="javascript_react", - ) + side_effect=[ + TemplateSelection( + architecture="dummy arch", + template="javascript_react", + ), + Architecture( + system_dependencies=[ + SystemDependency( + name="docker", + description="Docker is a containerization platform.", + test="docker --version", + required_locally=True, + ) + ], + package_dependencies=[ + PackageDependency( + name="express", + description="Express is a Node.js framework.", + ) + ], + ), + ] ) response = await arch.run() - arch.get_llm.return_value.assert_awaited_once() + arch.get_llm.return_value.assert_awaited() ui.ask_question.assert_awaited_once() pm.run_command.assert_awaited_once_with("docker --version") @@ -48,4 +52,4 @@ async def test_run(agentcontext): assert sm.current_state.specification.architecture == "dummy arch" assert sm.current_state.specification.system_dependencies[0]["name"] == "docker" assert sm.current_state.specification.package_dependencies[0]["name"] == "express" - assert sm.current_state.specification.template == "javascript_react" + assert "javascript_react" in sm.current_state.specification.templates diff --git a/tests/agents/test_tech_lead.py b/tests/agents/test_tech_lead.py index 30a3cc1a3..2476165be 100644 --- a/tests/agents/test_tech_lead.py +++ b/tests/agents/test_tech_lead.py @@ -31,7 +31,7 @@ async def test_create_initial_epic(agentcontext): async def test_apply_project_template(agentcontext): sm, _, ui, _ = agentcontext - sm.current_state.specification.template = "javascript_react" + sm.current_state.specification.templates = {"javascript_react": {}} sm.current_state.epics = [{"name": "Initial Project"}] await sm.commit() diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 8349832b3..9798f28ed 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -56,6 +56,7 @@ def test_parse_arguments(mock_ArgumentParser): "--import-v0", "--email", "--extension-version", + "--no-check", } parser.parse_args.assert_called_once_with() diff --git a/tests/templates/test_templates.py b/tests/templates/test_templates.py index ef30581ae..42efa5cf2 100644 --- a/tests/templates/test_templates.py +++ b/tests/templates/test_templates.py @@ -3,12 +3,12 @@ import pytest from core.state.state_manager import StateManager -from core.templates.registry import apply_project_template +from core.templates.registry import PROJECT_TEMPLATES @pytest.mark.asyncio @patch("core.state.state_manager.get_config") -async def test_render_javascript_react(mock_get_config, testmanager): +async def test_render_react_express_sql(mock_get_config, testmanager): mock_get_config.return_value.fs.type = "memory" sm = StateManager(testmanager) pm = MagicMock(run_command=AsyncMock()) @@ -16,24 +16,48 @@ async def test_render_javascript_react(mock_get_config, testmanager): await sm.create_project("TestProjectName") await sm.commit() - summary = await apply_project_template("javascript_react", sm, pm) - sm.next_state.specification.description = summary + TemplateClass = PROJECT_TEMPLATES["react_express"] + options = TemplateClass.options_class(db_type="sql", auth=True) + template = TemplateClass(options, sm, pm) + + assert template.options_dict == {"db_type": "sql", "auth": True} + + await template.apply() + + files = sm.file_system.list() + for f in ["server.js", "index.html", "prisma/schema.prisma", "api/routes/authRoutes.js", "ui/pages/Register.jsx"]: + assert f in files + assert "api/models/user.js" not in files + + +@pytest.mark.asyncio +@patch("core.state.state_manager.get_config") +async def test_render_react_express_nosql(mock_get_config, testmanager): + mock_get_config.return_value.fs.type = "memory" + sm = StateManager(testmanager) + pm = MagicMock(run_command=AsyncMock()) + + await sm.create_project("TestProjectName") await sm.commit() - files = [f.path for f in sm.current_state.files] - assert "React" in sm.current_state.specification.description - assert "package.json" in files + TemplateClass = PROJECT_TEMPLATES["react_express"] + options = TemplateClass.options_class(db_type="nosql", auth=True) + template = TemplateClass(options, sm, pm) + + assert template.options_dict == {"db_type": "nosql", "auth": True} - package_json = await sm.get_file_by_path("package.json") - assert package_json is not None - assert "TestProjectName" in package_json.content.content + await template.apply() - pm.run_command.assert_awaited_once_with("npm install") + files = sm.file_system.list() + print(files) + for f in ["server.js", "index.html", "api/models/user.js", "api/routes/authRoutes.js", "ui/pages/Register.jsx"]: + assert f in files + assert "prisma/schema.prisma" not in files @pytest.mark.asyncio @patch("core.state.state_manager.get_config") -async def test_render_node_express_mongoose(mock_get_config, testmanager): +async def test_render_javascript_react(mock_get_config, testmanager): mock_get_config.return_value.fs.type = "memory" sm = StateManager(testmanager) pm = MagicMock(run_command=AsyncMock()) @@ -41,16 +65,35 @@ async def test_render_node_express_mongoose(mock_get_config, testmanager): await sm.create_project("TestProjectName") await sm.commit() - summary = await apply_project_template("node_express_mongoose", sm, pm) - sm.next_state.specification.description = summary + TemplateClass = PROJECT_TEMPLATES["javascript_react"] + template = TemplateClass(TemplateClass.options_class(), sm, pm) + + assert template.options_dict == {} + + await template.apply() + + files = sm.file_system.list() + for f in ["src/App.jsx", "vite.config.js"]: + assert f in files + + +@pytest.mark.asyncio +@patch("core.state.state_manager.get_config") +async def test_render_node_express_mongoose(mock_get_config, testmanager): + mock_get_config.return_value.fs.type = "memory" + sm = StateManager(testmanager) + pm = MagicMock(run_command=AsyncMock()) + + await sm.create_project("TestProjectName") await sm.commit() - files = [f.path for f in sm.current_state.files] - assert "Mongoose" in sm.current_state.specification.description - assert "server.js" in files + TemplateClass = PROJECT_TEMPLATES["node_express_mongoose"] + template = TemplateClass(TemplateClass.options_class(), sm, pm) + + assert template.options_dict == {} - package_json = await sm.get_file_by_path("package.json") - assert package_json is not None - assert "TestProjectName" in package_json.content.content + await template.apply() - pm.run_command.assert_awaited_once_with("npm install") + files = sm.file_system.list() + for f in ["server.js", "models/User.js"]: + assert f in files From 85233d46a4883df8210c5f35e872f831a2e32614 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Thu, 27 Jun 2024 18:24:20 +0200 Subject: [PATCH 2/2] add jsonref dep --- core/agents/convo.py | 2 ++ pyproject.toml | 1 + requirements.txt | 1 + 3 files changed, 4 insertions(+) diff --git a/core/agents/convo.py b/core/agents/convo.py index 20b3ef1d6..0eb58f9d8 100644 --- a/core/agents/convo.py +++ b/core/agents/convo.py @@ -97,6 +97,8 @@ def remove_defs(d): else: return d + # We want to make the schema as simple as possible to avoid confusing the LLM, + # so we remove (dereference) all the refs we can and show the "final" schema version. schema_txt = json.dumps(remove_defs(jsonref.loads(json.dumps(model.model_json_schema())))) self.user( f"IMPORTANT: Your response MUST conform to this JSON schema:\n```\n{schema_txt}\n```." diff --git a/pyproject.toml b/pyproject.toml index 9ab84f4d2..195260da9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ httpx = "^0.27.0" alembic = "^1.13.1" python-dotenv = "^1.0.1" prompt-toolkit = "^3.0.45" +jsonref = "^1.1.0" [tool.poetry.group.dev.dependencies] pytest = "^8.1.1" diff --git a/requirements.txt b/requirements.txt index eef51be10..2ef591a07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ httpx==0.27.0 huggingface-hub==0.23.2 idna==3.7 jinja2==3.1.4 +jsonref==1.1.0 mako==1.3.5 markupsafe==2.1.5 openai==1.31.0