346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { z } from 'zod';
|
|
import { config } from '../../config.js';
|
|
import { detectProject } from '../../lib/frameworks/detect.js';
|
|
import { kebabCase, pascalCase, camelCase } from '../../lib/strings.js';
|
|
import { resolveWorkspacePath } from '../../lib/sandbox.js';
|
|
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
|
function pluralize(word) {
|
|
const lower = word.toLowerCase();
|
|
if (lower.endsWith('ies'))
|
|
return word;
|
|
if (lower.endsWith('y') && !/[aeiou]y$/i.test(word))
|
|
return `${word.slice(0, -1)}ies`;
|
|
if (/(s|x|z|ch|sh)$/i.test(word))
|
|
return `${word}es`;
|
|
if (lower.endsWith('s'))
|
|
return word;
|
|
return `${word}s`;
|
|
}
|
|
function requireDjango() {
|
|
const detected = detectProject(config.workspaceRoot);
|
|
if (detected.backend?.kind !== 'django') {
|
|
throw new Error('No Django project detected. Expected manage.py or a Django dependency file.');
|
|
}
|
|
return detected.backend;
|
|
}
|
|
function assertWritable(file, overwrite) {
|
|
if (!overwrite && fs.existsSync(file)) {
|
|
throw new Error(`"${file}" already exists. Pass overwrite: true to replace it.`);
|
|
}
|
|
}
|
|
export function registerGenerateDjangoResourceTool(server) {
|
|
server.registerTool('generate_django_resource', {
|
|
title: 'Generate Django Resource',
|
|
description: 'Scaffold a Django app resource: models.py model class, views (APIView-style stubs), urls.py, and admin registration. ' +
|
|
'Writes files under the given app directory (created if missing). Does not run migrate.',
|
|
inputSchema: {
|
|
name: z.string().min(1).describe('Resource/model name, e.g. "Post" or "blog_post".'),
|
|
app: z
|
|
.string()
|
|
.optional()
|
|
.describe('Django app directory relative to the project root. Defaults to a kebab-case plural of the name.'),
|
|
overwrite: z.boolean().optional().default(false),
|
|
},
|
|
}, async ({ name, app, overwrite }) => {
|
|
try {
|
|
const django = requireDjango();
|
|
const model = pascalCase(name);
|
|
const appName = app?.trim() || kebabCase(pluralize(model)).replace(/-/g, '_');
|
|
const appDir = resolveWorkspacePath(django.root, appName);
|
|
fs.mkdirSync(appDir, { recursive: true });
|
|
const files = [];
|
|
const modelFile = path.join(appDir, 'models.py');
|
|
const viewsFile = path.join(appDir, 'views.py');
|
|
const urlsFile = path.join(appDir, 'urls.py');
|
|
const adminFile = path.join(appDir, 'admin.py');
|
|
const appsFile = path.join(appDir, 'apps.py');
|
|
const initFile = path.join(appDir, '__init__.py');
|
|
for (const file of [modelFile, viewsFile, urlsFile, adminFile, appsFile]) {
|
|
assertWritable(file, overwrite ?? false);
|
|
}
|
|
if (!fs.existsSync(initFile)) {
|
|
fs.writeFileSync(initFile, '');
|
|
files.push(initFile);
|
|
}
|
|
fs.writeFileSync(modelFile, `from django.db import models
|
|
|
|
|
|
class ${model}(models.Model):
|
|
title = models.CharField(max_length=255)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
def __str__(self) -> str:
|
|
return self.title
|
|
`);
|
|
files.push(modelFile);
|
|
fs.writeFileSync(viewsFile, `from django.http import JsonResponse
|
|
from django.views import View
|
|
|
|
from .models import ${model}
|
|
|
|
|
|
class ${model}ListView(View):
|
|
def get(self, request):
|
|
items = list(${model}.objects.values("id", "title", "created_at"))
|
|
return JsonResponse({"results": items})
|
|
`);
|
|
files.push(viewsFile);
|
|
const routeName = kebabCase(pluralize(model));
|
|
fs.writeFileSync(urlsFile, `from django.urls import path
|
|
|
|
from .views import ${model}ListView
|
|
|
|
urlpatterns = [
|
|
path("${routeName}/", ${model}ListView.as_view(), name="${routeName}-list"),
|
|
]
|
|
`);
|
|
files.push(urlsFile);
|
|
fs.writeFileSync(adminFile, `from django.contrib import admin
|
|
|
|
from .models import ${model}
|
|
|
|
admin.site.register(${model})
|
|
`);
|
|
files.push(adminFile);
|
|
fs.writeFileSync(appsFile, `from django.apps import AppConfig
|
|
|
|
|
|
class ${pascalCase(appName)}Config(AppConfig):
|
|
default_auto_field = "django.db.models.BigAutoField"
|
|
name = "${appName}"
|
|
`);
|
|
files.push(appsFile);
|
|
return jsonResult({
|
|
kind: 'django',
|
|
model,
|
|
app: appName,
|
|
route: `/${routeName}/`,
|
|
files,
|
|
nextSteps: [
|
|
`Add "${appName}" to INSTALLED_APPS.`,
|
|
`Include path("${appName}/", include("${appName}.urls")) in the project urls.py.`,
|
|
'Run python manage.py makemigrations && python manage.py migrate.',
|
|
],
|
|
});
|
|
}
|
|
catch (error) {
|
|
return errorResult(toErrorMessage(error));
|
|
}
|
|
});
|
|
}
|
|
export function registerGenerateRailsResourceTool(server) {
|
|
server.registerTool('generate_rails_resource', {
|
|
title: 'Generate Rails Resource',
|
|
description: 'Scaffold Rails model + controller stubs and append a resources route hint to config/routes.rb when present. ' +
|
|
'File-based (does not invoke `rails generate`).',
|
|
inputSchema: {
|
|
name: z.string().min(1).describe('Resource name, e.g. "Post" or "blog_post".'),
|
|
api: z.boolean().optional().default(true).describe('If true, generate an API-style controller under app/controllers/api.'),
|
|
overwrite: z.boolean().optional().default(false),
|
|
},
|
|
}, async ({ name, api, overwrite }) => {
|
|
try {
|
|
const detected = detectProject(config.workspaceRoot);
|
|
if (detected.backend?.kind !== 'rails') {
|
|
return errorResult('No Rails project detected. Expected a Gemfile with rails and/or bin/rails.');
|
|
}
|
|
const root = detected.backend.root;
|
|
const model = pascalCase(name);
|
|
const plural = pluralize(model);
|
|
const controllerName = `${plural}Controller`;
|
|
const table = kebabCase(plural).replace(/-/g, '_');
|
|
const modelFile = resolveWorkspacePath(root, path.join('app', 'models', `${kebabCase(model).replace(/-/g, '_')}.rb`));
|
|
const controllerRel = api
|
|
? path.join('app', 'controllers', 'api', `${kebabCase(plural).replace(/-/g, '_')}_controller.rb`)
|
|
: path.join('app', 'controllers', `${kebabCase(plural).replace(/-/g, '_')}_controller.rb`);
|
|
const controllerFile = resolveWorkspacePath(root, controllerRel);
|
|
assertWritable(modelFile, overwrite ?? false);
|
|
assertWritable(controllerFile, overwrite ?? false);
|
|
fs.mkdirSync(path.dirname(modelFile), { recursive: true });
|
|
fs.mkdirSync(path.dirname(controllerFile), { recursive: true });
|
|
const files = [];
|
|
fs.writeFileSync(modelFile, `class ${model} < ApplicationRecord
|
|
# self.table_name = "${table}"
|
|
end
|
|
`);
|
|
files.push(modelFile);
|
|
const parent = api ? 'ApplicationController' : 'ApplicationController';
|
|
fs.writeFileSync(controllerFile, api
|
|
? `module Api
|
|
class ${controllerName} < ${parent}
|
|
def index
|
|
render json: ${model}.all
|
|
end
|
|
|
|
def show
|
|
render json: ${model}.find(params[:id])
|
|
end
|
|
end
|
|
end
|
|
`
|
|
: `class ${controllerName} < ${parent}
|
|
def index
|
|
@${camelCase(plural)} = ${model}.all
|
|
end
|
|
|
|
def show
|
|
@${camelCase(model)} = ${model}.find(params[:id])
|
|
end
|
|
end
|
|
`);
|
|
files.push(controllerFile);
|
|
const routesFile = path.join(root, 'config', 'routes.rb');
|
|
let routeWired = false;
|
|
const routeLine = api
|
|
? ` namespace :api do\n resources :${table}, only: %i[index show]\n end`
|
|
: ` resources :${table}, only: %i[index show]`;
|
|
if (fs.existsSync(routesFile)) {
|
|
const text = fs.readFileSync(routesFile, 'utf8');
|
|
if (!text.includes(`resources :${table}`)) {
|
|
const wired = text.includes('Rails.application.routes.draw')
|
|
? text.replace(/Rails\.application\.routes\.draw do\s*\n/, (match) => `${match}${routeLine}\n`)
|
|
: `${text.trimEnd()}\n${routeLine}\n`;
|
|
fs.writeFileSync(routesFile, wired);
|
|
routeWired = true;
|
|
files.push(routesFile);
|
|
}
|
|
}
|
|
return jsonResult({
|
|
kind: 'rails',
|
|
model,
|
|
controller: controllerName,
|
|
api: Boolean(api),
|
|
route: api ? `/api/${table}` : `/${table}`,
|
|
routeWired,
|
|
files,
|
|
nextSteps: [
|
|
'Add a migration for the model (bin/rails g migration Create...).',
|
|
routeWired ? 'Review config/routes.rb for the new resources entry.' : 'Add a resources route in config/routes.rb.',
|
|
],
|
|
});
|
|
}
|
|
catch (error) {
|
|
return errorResult(toErrorMessage(error));
|
|
}
|
|
});
|
|
}
|
|
export function registerGenerateSymfonyResourceTool(server) {
|
|
server.registerTool('generate_symfony_resource', {
|
|
title: 'Generate Symfony Resource',
|
|
description: 'Scaffold a Symfony Entity + Repository + Controller stub under src/. File-based (does not invoke MakerBundle).',
|
|
inputSchema: {
|
|
name: z.string().min(1).describe('Resource/entity name, e.g. "Post" or "blog_post".'),
|
|
overwrite: z.boolean().optional().default(false),
|
|
},
|
|
}, async ({ name, overwrite }) => {
|
|
try {
|
|
const detected = detectProject(config.workspaceRoot);
|
|
if (detected.backend?.kind !== 'symfony') {
|
|
return errorResult('No Symfony project detected. Expected symfony/framework-bundle or bin/console.');
|
|
}
|
|
const root = detected.backend.root;
|
|
const entity = pascalCase(name);
|
|
const route = kebabCase(pluralize(entity));
|
|
const entityFile = resolveWorkspacePath(root, path.join('src', 'Entity', `${entity}.php`));
|
|
const repoFile = resolveWorkspacePath(root, path.join('src', 'Repository', `${entity}Repository.php`));
|
|
const controllerFile = resolveWorkspacePath(root, path.join('src', 'Controller', `${entity}Controller.php`));
|
|
for (const file of [entityFile, repoFile, controllerFile]) {
|
|
assertWritable(file, overwrite ?? false);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
}
|
|
fs.writeFileSync(entityFile, `<?php
|
|
|
|
namespace App\\Entity;
|
|
|
|
use App\\Repository\\${entity}Repository;
|
|
use Doctrine\\ORM\\Mapping as ORM;
|
|
|
|
#[ORM\\Entity(repositoryClass: ${entity}Repository::class)]
|
|
class ${entity}
|
|
{
|
|
#[ORM\\Id]
|
|
#[ORM\\GeneratedValue]
|
|
#[ORM\\Column]
|
|
private ?int $id = null;
|
|
|
|
#[ORM\\Column(length: 255)]
|
|
private ?string $title = null;
|
|
|
|
public function getId(): ?int
|
|
{
|
|
return $this->id;
|
|
}
|
|
|
|
public function getTitle(): ?string
|
|
{
|
|
return $this->title;
|
|
}
|
|
|
|
public function setTitle(string $title): static
|
|
{
|
|
$this->title = $title;
|
|
return $this;
|
|
}
|
|
}
|
|
`);
|
|
fs.writeFileSync(repoFile, `<?php
|
|
|
|
namespace App\\Repository;
|
|
|
|
use App\\Entity\\${entity};
|
|
use Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;
|
|
use Doctrine\\Persistence\\ManagerRegistry;
|
|
|
|
/**
|
|
* @extends ServiceEntityRepository<${entity}>
|
|
*/
|
|
class ${entity}Repository extends ServiceEntityRepository
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, ${entity}::class);
|
|
}
|
|
}
|
|
`);
|
|
fs.writeFileSync(controllerFile, `<?php
|
|
|
|
namespace App\\Controller;
|
|
|
|
use App\\Repository\\${entity}Repository;
|
|
use Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;
|
|
use Symfony\\Component\\HttpFoundation\\JsonResponse;
|
|
use Symfony\\Component\\Routing\\Attribute\\Route;
|
|
|
|
class ${entity}Controller extends AbstractController
|
|
{
|
|
#[Route('/${route}', name: 'app_${route}_index', methods: ['GET'])]
|
|
public function index(${entity}Repository $repository): JsonResponse
|
|
{
|
|
$items = array_map(
|
|
static fn (${entity} $item) => ['id' => $item->getId(), 'title' => $item->getTitle()],
|
|
$repository->findAll(),
|
|
);
|
|
|
|
return $this->json(['results' => $items]);
|
|
}
|
|
}
|
|
`);
|
|
return jsonResult({
|
|
kind: 'symfony',
|
|
entity,
|
|
route: `/${route}`,
|
|
files: [entityFile, repoFile, controllerFile],
|
|
nextSteps: [
|
|
'Run doctrine migrations if Doctrine is configured (php bin/console make:migration / doctrine:migrations:migrate).',
|
|
'Ensure Doctrine ORM and attributes are enabled for the Entity mapping.',
|
|
],
|
|
});
|
|
}
|
|
catch (error) {
|
|
return errorResult(toErrorMessage(error));
|
|
}
|
|
});
|
|
}
|
|
//# sourceMappingURL=generateResources.js.map
|