Merge pull request #1038 from Pythagora-io/more-templates

Add new fullstack template (react+express) and support template options
This commit is contained in:
LeonOstrez
2024-07-02 12:22:07 +01:00
committed by GitHub
96 changed files with 2345 additions and 288 deletions

View File

@@ -1,3 +1,4 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
@@ -9,8 +10,12 @@ from core.db.models import Specification
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 @@ WARN_FRAMEWORKS_URL = "https://github.com/Pythagora-io/gpt-pilot/wiki/Using-GPT-
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 @@ class Architect(BaseAgent):
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 plan_architecture(self, spec: Specification):
await self.send_message("Planning project architecture ...")
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("technologies", templates=PROJECT_TEMPLATES).require_schema(Architecture)
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=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 @@ class Architect(BaseAgent):
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 @@ class Architect(BaseAgent):
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 @@ class Architect(BaseAgent):
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 @@ class Architect(BaseAgent):
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 @@ class Architect(BaseAgent):
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))

View File

@@ -3,6 +3,7 @@ import sys
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,19 @@ class AgentConvo(Convo):
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
# 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```."
f"YOU MUST NEVER add any additional fields to your response, and NEVER add additional preamble like 'Here is your JSON'."
)
return self

View File

@@ -106,6 +106,7 @@ class ErrorHandler(BaseAgent):
{
"id": uuid4().hex,
"user_feedback": f"Error running command: {cmd}",
"user_feedback_qa": None,
"description": llm_response,
"alternative_solutions": [],
"attempts": 1,

View File

@@ -194,9 +194,9 @@ class Orchestrator(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:

View File

@@ -1,4 +1,3 @@
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, Field
@@ -12,7 +11,7 @@ from core.llm.parser import JSONParser
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 @@ class TechLead(BaseAgent):
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 @@ class TechLead(BaseAgent):
}
]
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 template is specified
if len(state.epics) != 1 or not state.specification.template:
return None
# Only do this for the initial project and if the templates are specified
if len(state.epics) != 1 or not state.specification.templates:
return
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.
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,
)
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 @@ class TechLead(BaseAgent):
"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)
)

View File

@@ -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()

View File

@@ -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)

View File

@@ -28,6 +28,7 @@ DEFAULT_IGNORE_PATHS = [
"*.csv",
"*.log",
"go.sum",
"migration_lock.toml",
]
IGNORE_SIZE_THRESHOLD = 50000 # 50K+ files are ignored by default

View File

@@ -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 ###

View File

@@ -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 @@ class Specification(Base):
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,
)

View File

@@ -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 }}

View File

@@ -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
}
```

View File

@@ -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" %}
-----------------------------
Here is a high level description of "{{ state.branch.project.name }}":
```
{{ state.specification.description }}
```
Based on these details, think step by step to design the architecture for the project and choose technologies to use in building it.
Here is a short description of the project architecture:
{{ architecture }}
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 %}
Based on these details, think step by step and choose technologies to use in building it.
{% 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.
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 name, tpl in templates.items() %}
### {{ name }}
{{ tpl.description }}
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
]
}
```

View File

@@ -0,0 +1 @@
{% extends "troubleshooter/iteration.prompt" %}

140
core/templates/base.py Normal file
View File

@@ -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())

View File

@@ -18,13 +18,13 @@ Functional Specification:
- 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 @@ EXAMPLE_PROJECT_ARCHITECTURE = {
{"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 = [

View File

@@ -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

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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 @@ JAVASCRIPT_REACT = {
"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")

View File

@@ -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 @@ NODE_EXPRESS_MONGOOSE = {
"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")

View File

@@ -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

View File

@@ -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 @@ log = get_logger(__name__)
class ProjectTemplateEnum(str, Enum):
"""Choices of available project templates."""
NODE_EXPRESS_MONGOOSE = "node_express_mongoose"
JAVASCRIPT_REACT = "javascript_react"
JAVASCRIPT_REACT = JavascriptReactProjectTemplate.name
NODE_EXPRESS_MONGOOSE = NodeExpressMongooseProjectTemplate.name
REACT_EXPRESS = ReactExpressProjectTemplate.name
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,
},
)
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,
}

View File

@@ -8,6 +8,16 @@ from typing import Any, Callable
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 @@ class Renderer:
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:
"""

View File

@@ -0,0 +1,13 @@
{
"presets": [
[
"env",
{
"targets": {
"node": "current"
}
}
],
"jest"
]
}

View File

@@ -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 }}

View File

@@ -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)" }]
}
}

View File

@@ -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

View File

@@ -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`.

View File

@@ -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;

View File

@@ -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();
};

View File

@@ -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 });
};

View File

@@ -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 %}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
};

View File

@@ -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"
}
}

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ project_name }}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/ui/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./ui/*"
]
}
}
}

View File

@@ -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\""
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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 %}

View File

@@ -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();

View File

@@ -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")],
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./ui/*"
]
}
}
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { AlertCircle } from 'lucide-react';
export function AlertDestructive({ title, description }) {
return (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p className="font-bold"><AlertCircle className="inline-block mr-2 h-5 w-5" />{title}</p>
<p>{description}</p>
</div>
);
}

View File

@@ -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 (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }

View File

@@ -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) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

View File

@@ -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 (
<div className="w-full h-screen flex flex-col items-center justify-center px-4">
<h1 className="font-bold">Page Not Found</h1>
<p className="text-center">
Page <code>{pathname}</code> does not exist.
<br />
<a href="/" className="underline">Go home</a>
</p>
</div>
);
}
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
},
{% if options.auth %}
{
path: "/register/",
element: <Register />,
},
{
path: "/login/",
element: <Login />,
},
{% endif %}
{
path: "*",
element: <PageNotFound />,
}
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)

View File

@@ -0,0 +1,6 @@
#homePage {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}

View File

@@ -0,0 +1,11 @@
import './Home.css'
function Home() {
return (
<div id="homePage">
<h1>{{ project_name }}</h1>
</div>
)
}
export default Home

View File

@@ -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 (
<div id="loginPage" className="w-full h-screen flex items-center justify-center px-4">
<Card className="mx-auto max-w-sm">
{error && <AlertDestructive title="Login Error" description={error} />}
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
{false && <a href="#" className="ml-auto inline-block text-sm underline">
Forgot your password?
</a>}
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</>
) : (
'Login'
)}
</Button>
</form>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/register/" className="underline">
Sign up
</a>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<div id="registerPage" className="w-full h-screen flex items-center justify-center px-4">
<Card className="mx-auto max-w-sm">
{error && <AlertDestructive title="Registration Error" description={error} />}
<form onSubmit={handleSubmit}>
<CardHeader>
<CardTitle className="text-xl">Sign Up</CardTitle>
<CardDescription>
Enter your information to create an account
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</>
) : (
'Create an account'
)}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Already have an account?{" "}
<a href="/login" className="underline">
Sign in
</a>
</div>
</CardContent>
</form>
</Card>
</div>
);
}

View File

@@ -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,
}
}
}
})

View File

@@ -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"

View File

@@ -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

View File

@@ -2,7 +2,7 @@ from unittest.mock import AsyncMock
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

View File

@@ -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()

View File

@@ -56,6 +56,7 @@ def test_parse_arguments(mock_ArgumentParser):
"--import-v0",
"--email",
"--extension-version",
"--no-check",
}
parser.parse_args.assert_called_once_with()

View File

@@ -3,7 +3,56 @@ from unittest.mock import AsyncMock, MagicMock, patch
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_react_express_sql(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()
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()
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}
await template.apply()
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
@@ -16,19 +65,16 @@ 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
await sm.commit()
TemplateClass = PROJECT_TEMPLATES["javascript_react"]
template = TemplateClass(TemplateClass.options_class(), sm, pm)
files = [f.path for f in sm.current_state.files]
assert "React" in sm.current_state.specification.description
assert "package.json" in files
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 ["src/App.jsx", "vite.config.js"]:
assert f in files
@pytest.mark.asyncio
@@ -41,16 +87,13 @@ 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
await sm.commit()
TemplateClass = PROJECT_TEMPLATES["node_express_mongoose"]
template = TemplateClass(TemplateClass.options_class(), sm, pm)
files = [f.path for f in sm.current_state.files]
assert "Mongoose" in sm.current_state.specification.description
assert "server.js" in files
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