MCP Server 2.0 (#452)
* MCP Server 2.0 - Updated application version from 2.23.14 to 2.24.0 in package.json. - MCP Server improved with new features - Migrated functionality from Dev Tools to MCP Server - Improved getMonitoringProvider not to crash application when misconfigured
This commit is contained in:
committed by
GitHub
parent
059408a70a
commit
f3ac595d06
@@ -55,11 +55,13 @@ create policy "projects_write" on public.projects for all
|
||||
|
||||
Use `server-action-builder` skill for detailed patterns.
|
||||
|
||||
**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client, etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI command, or a unit test with zero changes.
|
||||
|
||||
Create in route's `_lib/server/` directory:
|
||||
|
||||
1. **Schema** (`_lib/schemas/feature.schema.ts`)
|
||||
2. **Service** (`_lib/server/feature.service.ts`)
|
||||
3. **Actions** (`_lib/server/server-actions.ts`)
|
||||
2. **Service** (`_lib/server/feature.service.ts`) — pure logic, dependencies injected, testable in isolation
|
||||
3. **Actions** (`_lib/server/server-actions.ts`) — thin adapter, no business logic
|
||||
|
||||
### Phase 3: UI Components
|
||||
|
||||
@@ -148,7 +150,9 @@ apps/web/app/home/[account]/projects/
|
||||
### Server Layer
|
||||
|
||||
- [ ] Zod schema in `_lib/schemas/`
|
||||
- [ ] Service class in `_lib/server/`
|
||||
- [ ] Service class in `_lib/server/` with dependencies injected (not imported)
|
||||
- [ ] Service contains all business logic — testable with mock dependencies
|
||||
- [ ] Server actions are thin adapters — resolve dependencies, call service, handle revalidation
|
||||
- [ ] Server actions use `enhanceAction`
|
||||
- [ ] Actions have `auth: true` and `schema` options
|
||||
- [ ] Logging added for operations
|
||||
|
||||
@@ -29,22 +29,24 @@ export type CreateFeatureInput = z.infer<typeof CreateFeatureSchema>;
|
||||
|
||||
### Step 2: Create Service Layer
|
||||
|
||||
**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, a CLI command, or a plain unit test.
|
||||
|
||||
Create service in `_lib/server/`:
|
||||
|
||||
```typescript
|
||||
// _lib/server/feature.service.ts
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { CreateFeatureInput } from '../schemas/feature.schema';
|
||||
|
||||
export function createFeatureService() {
|
||||
return new FeatureService();
|
||||
export function createFeatureService(client: SupabaseClient) {
|
||||
return new FeatureService(client);
|
||||
}
|
||||
|
||||
class FeatureService {
|
||||
async create(data: CreateFeatureInput) {
|
||||
const client = getSupabaseServerClient();
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
|
||||
const { data: result, error } = await client
|
||||
async create(data: CreateFeatureInput) {
|
||||
const { data: result, error } = await this.client
|
||||
.from('features')
|
||||
.insert({
|
||||
name: data.name,
|
||||
@@ -60,7 +62,11 @@ class FeatureService {
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create Server Action
|
||||
The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable (pass a mock client) and reusable (any interface can supply its own client).
|
||||
|
||||
### Step 3: Create Server Action (Thin Adapter)
|
||||
|
||||
The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business logic lives here.
|
||||
|
||||
Create action in `_lib/server/server-actions.ts`:
|
||||
|
||||
@@ -69,6 +75,7 @@ Create action in `_lib/server/server-actions.ts`:
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { CreateFeatureSchema } from '../schemas/feature.schema';
|
||||
@@ -81,7 +88,8 @@ export const createFeatureAction = enhanceAction(
|
||||
|
||||
logger.info(ctx, 'Creating feature');
|
||||
|
||||
const service = createFeatureService();
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createFeatureService(client);
|
||||
const result = await service.create(data);
|
||||
|
||||
logger.info({ ...ctx, featureId: result.id }, 'Feature created');
|
||||
@@ -99,11 +107,13 @@ export const createFeatureAction = enhanceAction(
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Schema in separate file** - Reusable between client and server
|
||||
2. **Service layer** - Business logic isolated from action
|
||||
3. **Logging** - Always log before and after operations
|
||||
4. **Revalidation** - Use `revalidatePath` after mutations
|
||||
5. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
||||
1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server action do the same thing, they call the same service function.
|
||||
2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with stubs and reusable across interfaces.
|
||||
3. **Schema in separate file** - Reusable between client and server
|
||||
4. **Logging** - Always log before and after operations
|
||||
5. **Revalidation** - Use `revalidatePath` after mutations
|
||||
6. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
||||
7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no running infrastructure
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
308
.claude/skills/service-builder/SKILL.md
Normal file
308
.claude/skills/service-builder/SKILL.md
Normal file
@@ -0,0 +1,308 @@
|
||||
---
|
||||
name: service-builder
|
||||
description: Build pure, interface-agnostic services with injected dependencies. Use when creating business logic that must work across server actions, MCP tools, CLI commands, or tests. Invoke with /service-builder.
|
||||
---
|
||||
|
||||
# Service Builder
|
||||
|
||||
You are an expert at building pure, testable services that are decoupled from their callers.
|
||||
|
||||
## North Star
|
||||
|
||||
**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route handler, or a test. The caller is a thin adapter that resolves dependencies and delegates.
|
||||
|
||||
## Workflow
|
||||
|
||||
When asked to create a service, follow these steps:
|
||||
|
||||
### Step 1: Define the Contract
|
||||
|
||||
Start with the input/output types. These are plain TypeScript — no framework types.
|
||||
|
||||
```typescript
|
||||
// _lib/schemas/project.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateProjectSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
account_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Build the Service
|
||||
|
||||
The service receives all dependencies through its constructor. It never imports framework-specific modules (`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.).
|
||||
|
||||
```typescript
|
||||
// _lib/server/project.service.ts
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateProjectInput, Project } from '../schemas/project.schema';
|
||||
|
||||
export function createProjectService(client: SupabaseClient) {
|
||||
return new ProjectService(client);
|
||||
}
|
||||
|
||||
class ProjectService {
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
|
||||
async create(data: CreateProjectInput): Promise<Project> {
|
||||
const { data: result, error } = await this.client
|
||||
.from('projects')
|
||||
.insert({
|
||||
name: data.name,
|
||||
account_id: data.accountId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async list(accountId: string): Promise<Project[]> {
|
||||
const { data, error } = await this.client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async delete(projectId: string): Promise<void> {
|
||||
const { error } = await this.client
|
||||
.from('projects')
|
||||
.delete()
|
||||
.eq('id', projectId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Write Thin Adapters
|
||||
|
||||
Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific concerns (revalidation, redirects, MCP formatting, CLI output).
|
||||
|
||||
**Server Action adapter:**
|
||||
|
||||
```typescript
|
||||
// _lib/server/server-actions.ts
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { CreateProjectSchema } from '../schemas/project.schema';
|
||||
import { createProjectService } from './project.service';
|
||||
|
||||
export const createProjectAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
logger.info({ name: 'create-project', userId: user.id }, 'Creating project');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createProjectService(client);
|
||||
const result = await service.create(data);
|
||||
|
||||
revalidatePath('/home/[account]/projects');
|
||||
|
||||
return { success: true, data: result };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Route Handler adapter:**
|
||||
|
||||
```typescript
|
||||
// app/api/projects/route.ts
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { CreateProjectSchema } from '../_lib/schemas/project.schema';
|
||||
import { createProjectService } from '../_lib/server/project.service';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user }) {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createProjectService(client);
|
||||
const result = await service.create(body);
|
||||
|
||||
return NextResponse.json(result);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**MCP Tool adapter:**
|
||||
|
||||
```typescript
|
||||
// mcp/tools/kit_project_create.ts
|
||||
import { createProjectService } from '../../_lib/server/project.service';
|
||||
|
||||
export const kit_project_create: McpToolHandler = async (input, context) => {
|
||||
const client = context.getSupabaseClient();
|
||||
const service = createProjectService(client);
|
||||
|
||||
return service.create(input);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: Write Tests
|
||||
|
||||
Because the service accepts dependencies, you can test it with stubs — no running database, no framework runtime.
|
||||
|
||||
```typescript
|
||||
// _lib/server/__tests__/project.service.test.ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { createProjectService } from '../project.service';
|
||||
|
||||
function createMockClient(overrides: Record<string, unknown> = {}) {
|
||||
const mockChain = {
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: { id: 'proj-1', name: 'Test', account_id: 'acc-1', created_at: new Date().toISOString() },
|
||||
error: null,
|
||||
}),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
order: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
from: vi.fn(() => mockChain),
|
||||
mockChain,
|
||||
} as unknown as SupabaseClient;
|
||||
}
|
||||
|
||||
describe('ProjectService', () => {
|
||||
it('creates a project', async () => {
|
||||
const client = createMockClient();
|
||||
const service = createProjectService(client);
|
||||
|
||||
const result = await service.create({
|
||||
name: 'Test Project',
|
||||
accountId: 'acc-1',
|
||||
});
|
||||
|
||||
expect(result.id).toBe('proj-1');
|
||||
expect(client.from).toHaveBeenCalledWith('projects');
|
||||
});
|
||||
|
||||
it('throws on database error', async () => {
|
||||
const client = createMockClient({
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'unique violation' },
|
||||
}),
|
||||
});
|
||||
|
||||
const service = createProjectService(client);
|
||||
|
||||
await expect(
|
||||
service.create({ name: 'Dup', accountId: 'acc-1' }),
|
||||
).rejects.toEqual({ message: 'unique violation' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/`Response`, no MCP context, no `FormData`.
|
||||
|
||||
2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service.
|
||||
|
||||
3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters.
|
||||
|
||||
4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating logic is a violation.
|
||||
|
||||
5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, refactor until you don't.
|
||||
|
||||
## What Goes Where
|
||||
|
||||
| Concern | Location | Example |
|
||||
|---------|----------|---------|
|
||||
| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` |
|
||||
| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` |
|
||||
| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper |
|
||||
| Logging | Adapter | `logger.info()` before/after service call |
|
||||
| Cache revalidation | Adapter | `revalidatePath()` after mutation |
|
||||
| Redirect | Adapter | `redirect()` after creation |
|
||||
| MCP response format | Adapter | Return service result as MCP content |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
feature/
|
||||
├── _lib/
|
||||
│ ├── schemas/
|
||||
│ │ └── feature.schema.ts # Zod schemas + TS types
|
||||
│ └── server/
|
||||
│ ├── feature.service.ts # Pure service (dependencies injected)
|
||||
│ ├── server-actions.ts # Server action adapters
|
||||
│ └── __tests__/
|
||||
│ └── feature.service.test.ts # Unit tests with mock client
|
||||
└── _components/
|
||||
└── feature-form.tsx
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Service imports framework-specific client
|
||||
class ProjectService {
|
||||
async create(data: CreateProjectInput) {
|
||||
const client = getSupabaseServerClient(); // coupling!
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ BAD: Business logic in the adapter
|
||||
export const createProjectAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
// Business logic directly in the action — not reusable
|
||||
if (data.name.length > 100) throw new Error('Name too long');
|
||||
const { data: result } = await client.from('projects').insert(data);
|
||||
return result;
|
||||
},
|
||||
{ auth: true, schema: CreateProjectSchema },
|
||||
);
|
||||
|
||||
// ❌ BAD: Two interfaces duplicate the same logic
|
||||
// server-actions.ts
|
||||
const result = await client.from('projects').insert(...).select().single();
|
||||
// mcp-tool.ts
|
||||
const result = await client.from('projects').insert(...).select().single();
|
||||
// Should be: both call projectService.create()
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose other services, and testing strategies.
|
||||
273
.claude/skills/service-builder/examples.md
Normal file
273
.claude/skills/service-builder/examples.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Service Builder Examples
|
||||
|
||||
## Service with Multiple Dependencies
|
||||
|
||||
When a service needs more than just a database client, inject all dependencies.
|
||||
|
||||
```typescript
|
||||
// _lib/server/invoice.service.ts
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
interface InvoiceServiceDeps {
|
||||
client: SupabaseClient;
|
||||
storage: SupabaseClient['storage'];
|
||||
}
|
||||
|
||||
export function createInvoiceService(deps: InvoiceServiceDeps) {
|
||||
return new InvoiceService(deps);
|
||||
}
|
||||
|
||||
class InvoiceService {
|
||||
constructor(private readonly deps: InvoiceServiceDeps) {}
|
||||
|
||||
async generatePdf(invoiceId: string): Promise<{ url: string }> {
|
||||
const { data: invoice, error } = await this.deps.client
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('id', invoiceId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const pdf = this.buildPdf(invoice);
|
||||
|
||||
const { data: upload, error: uploadError } = await this.deps.storage
|
||||
.from('invoices')
|
||||
.upload(`${invoiceId}.pdf`, pdf);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
return { url: upload.path };
|
||||
}
|
||||
|
||||
private buildPdf(invoice: Record<string, unknown>): Uint8Array {
|
||||
// Pure logic — no I/O
|
||||
// ...
|
||||
return new Uint8Array();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server action adapter:**
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { GenerateInvoiceSchema } from '../schemas/invoice.schema';
|
||||
import { createInvoiceService } from './invoice.service';
|
||||
|
||||
export const generateInvoicePdfAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createInvoiceService({
|
||||
client,
|
||||
storage: client.storage,
|
||||
});
|
||||
|
||||
return service.generatePdf(data.invoiceId);
|
||||
},
|
||||
{ auth: true, schema: GenerateInvoiceSchema },
|
||||
);
|
||||
```
|
||||
|
||||
## Service Composing Other Services
|
||||
|
||||
Services can depend on other services — compose at the adapter level.
|
||||
|
||||
```typescript
|
||||
// _lib/server/onboarding.service.ts
|
||||
import type { ProjectService } from './project.service';
|
||||
import type { NotificationService } from './notification.service';
|
||||
|
||||
interface OnboardingServiceDeps {
|
||||
projects: ProjectService;
|
||||
notifications: NotificationService;
|
||||
}
|
||||
|
||||
export function createOnboardingService(deps: OnboardingServiceDeps) {
|
||||
return new OnboardingService(deps);
|
||||
}
|
||||
|
||||
class OnboardingService {
|
||||
constructor(private readonly deps: OnboardingServiceDeps) {}
|
||||
|
||||
async onboardAccount(params: { accountId: string; accountName: string }) {
|
||||
// Create default project
|
||||
const project = await this.deps.projects.create({
|
||||
name: `${params.accountName}'s First Project`,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
// Send welcome notification
|
||||
await this.deps.notifications.send({
|
||||
accountId: params.accountId,
|
||||
type: 'welcome',
|
||||
data: { projectId: project.id },
|
||||
});
|
||||
|
||||
return { project };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Adapter composes the dependency tree:**
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createOnboardingService } from './onboarding.service';
|
||||
import { createProjectService } from './project.service';
|
||||
import { createNotificationService } from './notification.service';
|
||||
|
||||
export const onboardAccountAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const service = createOnboardingService({
|
||||
projects: createProjectService(client),
|
||||
notifications: createNotificationService(client),
|
||||
});
|
||||
|
||||
return service.onboardAccount(data);
|
||||
},
|
||||
{ auth: true, schema: OnboardAccountSchema },
|
||||
);
|
||||
```
|
||||
|
||||
## Pure Validation Service (No I/O)
|
||||
|
||||
Some services are entirely pure — they don't even need a database client.
|
||||
|
||||
```typescript
|
||||
// _lib/server/pricing.service.ts
|
||||
|
||||
interface PricingInput {
|
||||
plan: 'starter' | 'pro' | 'enterprise';
|
||||
seats: number;
|
||||
billingPeriod: 'monthly' | 'yearly';
|
||||
}
|
||||
|
||||
interface PricingResult {
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
discount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export function calculatePricing(input: PricingInput): PricingResult {
|
||||
const basePrices = { starter: 900, pro: 2900, enterprise: 9900 };
|
||||
const unitPrice = basePrices[input.plan];
|
||||
const yearlyDiscount = input.billingPeriod === 'yearly' ? 0.2 : 0;
|
||||
const seatDiscount = input.seats >= 10 ? 0.1 : 0;
|
||||
const discount = Math.min(yearlyDiscount + seatDiscount, 0.3);
|
||||
const total = Math.round(unitPrice * input.seats * (1 - discount));
|
||||
|
||||
return { unitPrice, total, discount, currency: 'usd' };
|
||||
}
|
||||
```
|
||||
|
||||
This is the simplest case — a plain function, no class, no dependencies. Trivially testable:
|
||||
|
||||
```typescript
|
||||
import { calculatePricing } from '../pricing.service';
|
||||
|
||||
it('applies yearly discount', () => {
|
||||
const result = calculatePricing({
|
||||
plan: 'pro',
|
||||
seats: 1,
|
||||
billingPeriod: 'yearly',
|
||||
});
|
||||
|
||||
expect(result.discount).toBe(0.2);
|
||||
expect(result.total).toBe(2320); // 2900 * 0.8
|
||||
});
|
||||
```
|
||||
|
||||
## Testing with Mock Client
|
||||
|
||||
Full mock pattern for Supabase client:
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
* Creates a chainable mock that mimics Supabase's query builder.
|
||||
* Override any method in the chain via the `overrides` param.
|
||||
*/
|
||||
export function createMockSupabaseClient(
|
||||
resolvedValue: { data: unknown; error: unknown } = { data: null, error: null },
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
|
||||
|
||||
// Every method returns `this` (chainable) by default
|
||||
const methods = [
|
||||
'select', 'insert', 'update', 'upsert', 'delete',
|
||||
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in',
|
||||
'like', 'ilike', 'is', 'order', 'limit', 'range',
|
||||
'single', 'maybeSingle',
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
chain[method] = vi.fn().mockReturnThis();
|
||||
}
|
||||
|
||||
// Terminal methods resolve with data
|
||||
chain.single = vi.fn().mockResolvedValue(resolvedValue);
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(resolvedValue);
|
||||
|
||||
// Apply overrides
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
chain[key] = vi.fn().mockImplementation(
|
||||
typeof value === 'function' ? value : () => value,
|
||||
);
|
||||
}
|
||||
|
||||
// Non-terminal chains that don't end with single/maybeSingle
|
||||
// resolve when awaited via .then()
|
||||
const proxyHandler: ProxyHandler<typeof chain> = {
|
||||
get(target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(resolvedValue);
|
||||
}
|
||||
return target[prop as string] ?? vi.fn().mockReturnValue(target);
|
||||
},
|
||||
};
|
||||
|
||||
const chainProxy = new Proxy(chain, proxyHandler);
|
||||
|
||||
return {
|
||||
from: vi.fn(() => chainProxy),
|
||||
chain,
|
||||
} as unknown as SupabaseClient & { chain: typeof chain };
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```typescript
|
||||
import { createMockSupabaseClient } from '../test-utils';
|
||||
import { createProjectService } from '../project.service';
|
||||
|
||||
it('lists projects for an account', async () => {
|
||||
const projects = [
|
||||
{ id: '1', name: 'Alpha', account_id: 'acc-1' },
|
||||
{ id: '2', name: 'Beta', account_id: 'acc-1' },
|
||||
];
|
||||
|
||||
const client = createMockSupabaseClient({ data: projects, error: null });
|
||||
const service = createProjectService(client);
|
||||
|
||||
const result = await service.list('acc-1');
|
||||
|
||||
expect(result).toEqual(projects);
|
||||
expect(client.from).toHaveBeenCalledWith('projects');
|
||||
expect(client.chain.eq).toHaveBeenCalledWith('account_id', 'acc-1');
|
||||
});
|
||||
```
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,3 +48,6 @@ yarn-error.log*
|
||||
|
||||
# ts-node cache
|
||||
node-compile-cache/
|
||||
|
||||
# prds
|
||||
.prds/
|
||||
@@ -3,7 +3,7 @@
|
||||
"makerkit": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["packages/mcp-server/build/index.js"]
|
||||
"args": ["packages/mcp-server/build/index.cjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { DatabaseToolsInterface } from './_components/database-tools-interface';
|
||||
import { loadDatabaseToolsData } from './_lib/server/database-tools.loader';
|
||||
|
||||
interface DatabasePageProps {
|
||||
searchParams: {
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
async function DatabasePage({ searchParams }: DatabasePageProps) {
|
||||
const searchTerm = searchParams.search || '';
|
||||
const searchTerm = (await searchParams).search || '';
|
||||
|
||||
// Load all database data server-side
|
||||
const databaseData = await loadDatabaseToolsData();
|
||||
@@ -1,10 +1,12 @@
|
||||
import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form';
|
||||
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
|
||||
import { getVariable } from '@/app/variables/lib/env-scanner';
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
import { EnvModeSelector } from '@/components/env-mode-selector';
|
||||
import { IFrame } from '@/components/iframe';
|
||||
|
||||
import {
|
||||
createKitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from '@kit/mcp-server/emails';
|
||||
import { findWorkspaceRoot, getVariable } from '@kit/mcp-server/env';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
} from '@kit/ui/dialog';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
type EnvMode = 'development' | 'production';
|
||||
|
||||
type EmailPageProps = React.PropsWithChildren<{
|
||||
params: Promise<{
|
||||
id: string;
|
||||
@@ -31,25 +35,28 @@ export default async function EmailPage(props: EmailPageProps) {
|
||||
const { id } = await props.params;
|
||||
const mode = (await props.searchParams).mode ?? 'development';
|
||||
|
||||
const template = await loadEmailTemplate(id);
|
||||
const emailSettings = await getEmailSettings(mode);
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||
|
||||
const values: Record<string, string> = {
|
||||
emails: 'Emails',
|
||||
'invite-email': 'Invite Email',
|
||||
'account-delete-email': 'Account Delete Email',
|
||||
'confirm-email': 'Confirm Email',
|
||||
'change-email-address-email': 'Change Email Address Email',
|
||||
'reset-password-email': 'Reset Password Email',
|
||||
'magic-link-email': 'Magic Link Email',
|
||||
'otp-email': 'OTP Email',
|
||||
};
|
||||
const [result, { templates }, emailSettings] = await Promise.all([
|
||||
service.read({ id }),
|
||||
service.list(),
|
||||
getEmailSettings(mode),
|
||||
]);
|
||||
|
||||
const html = result.renderedHtml ?? result.source;
|
||||
|
||||
const values: Record<string, string> = { emails: 'Emails' };
|
||||
|
||||
for (const t of templates) {
|
||||
values[t.id] = t.name;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
<PageHeader
|
||||
displaySidebarTrigger={false}
|
||||
title={values[id]}
|
||||
title={values[id] ?? id}
|
||||
description={<AppBreadcrumbs values={values} />}
|
||||
>
|
||||
<EnvModeSelector mode={mode} />
|
||||
@@ -77,7 +84,7 @@ export default async function EmailPage(props: EmailPageProps) {
|
||||
<IFrame className={'flex flex-1 flex-col'}>
|
||||
<div
|
||||
className={'flex flex-1 flex-col'}
|
||||
dangerouslySetInnerHTML={{ __html: template.html }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</IFrame>
|
||||
</PageBody>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import {
|
||||
renderAccountDeleteEmail,
|
||||
renderInviteEmail,
|
||||
renderOtpEmail,
|
||||
} from '@kit/email-templates';
|
||||
|
||||
export async function loadEmailTemplate(id: string) {
|
||||
switch (id) {
|
||||
case 'account-delete-email':
|
||||
return renderAccountDeleteEmail({
|
||||
productName: 'Makerkit',
|
||||
userDisplayName: 'Giancarlo',
|
||||
});
|
||||
|
||||
case 'invite-email':
|
||||
return renderInviteEmail({
|
||||
teamName: 'Makerkit',
|
||||
teamLogo: '',
|
||||
inviter: 'Giancarlo',
|
||||
invitedUserEmail: 'test@makerkit.dev',
|
||||
link: 'https://makerkit.dev',
|
||||
productName: 'Makerkit',
|
||||
});
|
||||
|
||||
case 'otp-email':
|
||||
return renderOtpEmail({
|
||||
productName: 'Makerkit',
|
||||
otp: '123456',
|
||||
});
|
||||
|
||||
case 'magic-link-email':
|
||||
return loadFromFileSystem('magic-link');
|
||||
|
||||
case 'reset-password-email':
|
||||
return loadFromFileSystem('reset-password');
|
||||
|
||||
case 'change-email-address-email':
|
||||
return loadFromFileSystem('change-email-address');
|
||||
|
||||
case 'confirm-email':
|
||||
return loadFromFileSystem('confirm-email');
|
||||
|
||||
default:
|
||||
throw new Error(`Email template not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromFileSystem(fileName: string) {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
|
||||
const filePath = join(
|
||||
process.cwd(),
|
||||
`../web/supabase/templates/${fileName}.html`,
|
||||
);
|
||||
|
||||
return {
|
||||
html: readFileSync(filePath, 'utf8'),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
'use server';
|
||||
|
||||
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
|
||||
import {
|
||||
createKitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from '@kit/mcp-server/emails';
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
|
||||
export async function sendEmailAction(params: {
|
||||
template: string;
|
||||
@@ -27,7 +31,10 @@ export async function sendEmailAction(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const { html } = await loadEmailTemplate(params.template);
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||
const result = await service.read({ id: params.template });
|
||||
const html = result.renderedHtml ?? result.source;
|
||||
|
||||
return transporter.sendMail({
|
||||
html,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
createKitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from '@kit/mcp-server/emails';
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
import {
|
||||
CardButton,
|
||||
CardButtonHeader,
|
||||
@@ -12,7 +17,16 @@ export const metadata = {
|
||||
title: 'Emails',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'supabase-auth': 'Supabase Auth Emails',
|
||||
transactional: 'Transactional Emails',
|
||||
};
|
||||
|
||||
export default async function EmailsPage() {
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||
const { templates, categories } = await service.list();
|
||||
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
<PageHeader
|
||||
@@ -22,73 +36,31 @@ export default async function EmailsPage() {
|
||||
/>
|
||||
|
||||
<PageBody className={'gap-y-8'}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={5}>Supabase Auth Emails</Heading>
|
||||
{categories.map((category) => {
|
||||
const categoryTemplates = templates.filter(
|
||||
(t) => t.category === category,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={category} className={'flex flex-col space-y-4'}>
|
||||
<Heading level={5}>
|
||||
{CATEGORY_LABELS[category] ?? category}
|
||||
</Heading>
|
||||
|
||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/confirm-email'}>
|
||||
{categoryTemplates.map((template) => (
|
||||
<CardButton key={template.id} asChild>
|
||||
<Link href={`/emails/${template.id}`}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Confirm Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/change-email-address-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Change Email Address Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/reset-password-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Reset Password Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/magic-link-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Magic Link Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={5}>Transactional Emails</Heading>
|
||||
|
||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/account-delete-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Account Delete Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/invite-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Invite Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/otp-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>OTP Email</CardButtonTitle>
|
||||
<CardButtonTitle>{template.name}</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
|
||||
import { getVariable } from '../variables/lib/env-scanner';
|
||||
|
||||
export function createConnectivityService(mode: EnvMode) {
|
||||
return new ConnectivityService(mode);
|
||||
}
|
||||
|
||||
class ConnectivityService {
|
||||
constructor(private mode: EnvMode = 'development') {}
|
||||
|
||||
async checkSupabaseConnectivity() {
|
||||
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase URL found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const anonKey =
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_ANON_KEY', this.mode)) ||
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', this.mode));
|
||||
|
||||
if (!anonKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase Anon Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}/auth/v1/health`, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${anonKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message:
|
||||
'Failed to connect to Supabase. The Supabase Anon Key or URL is not valid.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'Connected to Supabase',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: `Failed to connect to Supabase. ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkSupabaseAdminConnectivity() {
|
||||
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase URL found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const endpoint = `${url}/rest/v1/accounts`;
|
||||
|
||||
const apikey =
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_ANON_KEY', this.mode)) ||
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', this.mode));
|
||||
|
||||
if (!apikey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase Anon Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const adminKey =
|
||||
(await getVariable('SUPABASE_SERVICE_ROLE_KEY', this.mode)) ||
|
||||
(await getVariable('SUPABASE_SECRET_KEY', this.mode));
|
||||
|
||||
if (!adminKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase Service Role Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
apikey: adminKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message:
|
||||
'Failed to connect to Supabase Admin. The Supabase Service Role Key is not valid.',
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message:
|
||||
'No accounts found in Supabase Admin. The data may not be seeded. Please run `pnpm run supabase:web:reset` to reset the database.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'Connected to Supabase Admin',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: `Failed to connect to Supabase Admin. ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkStripeWebhookEndpoints() {
|
||||
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Stripe Secret Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const webhooksSecret = await getVariable(
|
||||
'STRIPE_WEBHOOK_SECRET',
|
||||
this.mode,
|
||||
);
|
||||
|
||||
if (!webhooksSecret) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Webhooks secret found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const url = `https://api.stripe.com`;
|
||||
|
||||
const request = await fetch(`${url}/v1/webhook_endpoints`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request.ok) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message:
|
||||
'Failed to connect to Stripe. The Stripe Webhook Secret is not valid.',
|
||||
};
|
||||
}
|
||||
|
||||
const webhooksResponse = await request.json();
|
||||
const webhooks = webhooksResponse.data ?? [];
|
||||
|
||||
if (webhooks.length === 0) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No webhooks found in Stripe',
|
||||
};
|
||||
}
|
||||
|
||||
const allWebhooksShareTheSameSecret = webhooks.every(
|
||||
(webhook: { secret: string }) => webhook.secret === webhooksSecret,
|
||||
);
|
||||
|
||||
if (!allWebhooksShareTheSameSecret) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'All webhooks do not share the same secret',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'All webhooks share the same Webhooks secret',
|
||||
};
|
||||
}
|
||||
|
||||
async checkStripeConnected() {
|
||||
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Stripe Secret Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const url = `https://api.stripe.com`;
|
||||
|
||||
const request = await fetch(`${url}/v1/prices`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request.ok) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message:
|
||||
'Failed to connect to Stripe. The Stripe Secret Key is not valid.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'Connected to Stripe',
|
||||
};
|
||||
}
|
||||
}
|
||||
154
apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts
Normal file
154
apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type KitPrerequisitesDeps,
|
||||
createKitPrerequisitesService,
|
||||
} from '@kit/mcp-server/prerequisites';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function loadDashboardKitPrerequisites() {
|
||||
const rootPath = await findWorkspaceRoot(process.cwd());
|
||||
const service = createKitPrerequisitesService(
|
||||
createKitPrerequisitesDeps(rootPath),
|
||||
);
|
||||
return service.check({});
|
||||
}
|
||||
|
||||
function createKitPrerequisitesDeps(rootPath: string): KitPrerequisitesDeps {
|
||||
return {
|
||||
async getVariantFamily() {
|
||||
const variant = await resolveVariant(rootPath);
|
||||
return variant.includes('supabase') ? 'supabase' : 'orm';
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await executeWithFallback(rootPath, command, args);
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeWithFallback(
|
||||
rootPath: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
) {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLocalCliCandidate(command)) {
|
||||
const localBinCandidates = [
|
||||
join(rootPath, 'node_modules', '.bin', command),
|
||||
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
|
||||
];
|
||||
|
||||
for (const localBin of localBinCandidates) {
|
||||
try {
|
||||
return await execFileAsync(localBin, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
// Try next local binary candidate.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await execFileAsync('pnpm', ['exec', command, ...args], {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
return execFileAsync(
|
||||
'pnpm',
|
||||
['--filter', 'web', 'exec', command, ...args],
|
||||
{
|
||||
cwd: rootPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalCliCandidate(command: string) {
|
||||
return command === 'supabase' || command === 'stripe';
|
||||
}
|
||||
|
||||
async function resolveVariant(rootPath: string) {
|
||||
const configPath = join(rootPath, '.makerkit', 'config.json');
|
||||
|
||||
try {
|
||||
await access(configPath);
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const variant =
|
||||
readString(config, 'variant') ??
|
||||
readString(config, 'template') ??
|
||||
readString(config, 'kitVariant');
|
||||
|
||||
if (variant) {
|
||||
return variant;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to heuristic.
|
||||
}
|
||||
|
||||
if (await pathExists(join(rootPath, 'apps', 'web', 'supabase'))) {
|
||||
return 'next-supabase';
|
||||
}
|
||||
|
||||
return 'next-drizzle';
|
||||
}
|
||||
|
||||
function readString(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findWorkspaceRoot(startPath: string) {
|
||||
let current = startPath;
|
||||
|
||||
for (let depth = 0; depth < 6; depth++) {
|
||||
const workspaceManifest = join(current, 'pnpm-workspace.yaml');
|
||||
|
||||
try {
|
||||
await access(workspaceManifest);
|
||||
return current;
|
||||
} catch {
|
||||
// Continue to parent path.
|
||||
}
|
||||
|
||||
const parent = join(current, '..');
|
||||
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return startPath;
|
||||
}
|
||||
116
apps/dev-tool/app/lib/status-dashboard.loader.ts
Normal file
116
apps/dev-tool/app/lib/status-dashboard.loader.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import { Socket } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type KitStatusDeps,
|
||||
createKitStatusService,
|
||||
} from '@kit/mcp-server/status';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function loadDashboardKitStatus() {
|
||||
const rootPath = await findWorkspaceRoot(process.cwd());
|
||||
const service = createKitStatusService(createKitStatusDeps(rootPath));
|
||||
return service.getStatus({});
|
||||
}
|
||||
|
||||
function createKitStatusDeps(rootPath: string): KitStatusDeps {
|
||||
return {
|
||||
rootPath,
|
||||
async readJsonFile(path: string): Promise<unknown> {
|
||||
const filePath = join(rootPath, path);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as unknown;
|
||||
},
|
||||
async pathExists(path: string) {
|
||||
const fullPath = join(rootPath, path);
|
||||
|
||||
try {
|
||||
await access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async isDirectory(path: string) {
|
||||
const fullPath = join(rootPath, path);
|
||||
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
return stats.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
async isPortOpen(port: number) {
|
||||
return checkPort(port);
|
||||
},
|
||||
getNodeVersion() {
|
||||
return process.version;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function findWorkspaceRoot(startPath: string) {
|
||||
let current = startPath;
|
||||
|
||||
for (let depth = 0; depth < 6; depth++) {
|
||||
const workspaceManifest = join(current, 'pnpm-workspace.yaml');
|
||||
|
||||
try {
|
||||
await access(workspaceManifest);
|
||||
return current;
|
||||
} catch {
|
||||
// Continue to parent path.
|
||||
}
|
||||
|
||||
const parent = join(current, '..');
|
||||
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return startPath;
|
||||
}
|
||||
|
||||
async function checkPort(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new Socket();
|
||||
|
||||
socket.setTimeout(200);
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||
import { McpServerTabs } from './mcp-server-tabs';
|
||||
import { PRDManagerClient } from './prd-manager-client';
|
||||
|
||||
export async function McpServerInterface() {
|
||||
const initialPrds = await loadPRDs();
|
||||
|
||||
return (
|
||||
<McpServerTabs
|
||||
prdManagerContent={<PRDManagerClient initialPrds={initialPrds} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DatabaseIcon, FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||
|
||||
interface McpServerTabsProps {
|
||||
prdManagerContent: React.ReactNode;
|
||||
databaseToolsContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function McpServerTabs({
|
||||
prdManagerContent,
|
||||
databaseToolsContent,
|
||||
}: McpServerTabsProps) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Tabs defaultValue="database-tools" className="flex h-full flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger
|
||||
value="database-tools"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DatabaseIcon className="h-4 w-4" />
|
||||
Database Tools
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="prd-manager" className="flex items-center gap-2">
|
||||
<FileTextIcon className="h-4 w-4" />
|
||||
PRD Manager
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="database-tools" className="flex-1 space-y-4">
|
||||
{databaseToolsContent || (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<DatabaseIcon className="text-muted-foreground mx-auto h-12 w-12" />
|
||||
<h3 className="mt-4 text-lg font-semibold">Database Tools</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Explore database schemas, tables, functions, and enums
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="prd-manager" className="flex-1 space-y-4">
|
||||
{prdManagerContent}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarIcon, FileTextIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Progress } from '@kit/ui/progress';
|
||||
|
||||
import type { CreatePRDData } from '../_lib/schemas/create-prd.schema';
|
||||
import { createPRDAction } from '../_lib/server/prd-server-actions';
|
||||
import { CreatePRDForm } from './create-prd-form';
|
||||
|
||||
interface PRDSummary {
|
||||
filename: string;
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
progress: number;
|
||||
totalStories: number;
|
||||
completedStories: number;
|
||||
}
|
||||
|
||||
interface PRDManagerClientProps {
|
||||
initialPrds: PRDSummary[];
|
||||
}
|
||||
|
||||
export function PRDManagerClient({ initialPrds }: PRDManagerClientProps) {
|
||||
const [prds, setPrds] = useState<PRDSummary[]>(initialPrds);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
const handleCreatePRD = async (data: CreatePRDData) => {
|
||||
const result = await createPRDAction(data);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const newPRD: PRDSummary = {
|
||||
filename: result.data.filename,
|
||||
title: result.data.title,
|
||||
lastUpdated: result.data.lastUpdated,
|
||||
progress: result.data.progress,
|
||||
totalStories: result.data.totalStories,
|
||||
completedStories: result.data.completedStories,
|
||||
};
|
||||
|
||||
setPrds((prev) => [...prev, newPRD]);
|
||||
setShowCreateForm(false);
|
||||
|
||||
// Note: In a production app, you might want to trigger a router.refresh()
|
||||
// to reload the server component and get the most up-to-date data
|
||||
} else {
|
||||
// Error handling will be managed by the form component via the action result
|
||||
throw new Error(result.error || 'Failed to create PRD');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPrds = prds.filter(
|
||||
(prd) =>
|
||||
prd.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prd.filename.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with search and create button */}
|
||||
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
|
||||
<Input
|
||||
placeholder="Search PRDs by title or filename..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create New PRD
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PRD List */}
|
||||
{filteredPrds.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileTextIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm ? 'No PRDs match your search' : 'No PRDs found'}
|
||||
</p>
|
||||
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
Create your first PRD
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPrds.map((prd) => (
|
||||
<Link
|
||||
key={prd.filename}
|
||||
href={`/mcp-server/prds/${prd.filename}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start gap-2 text-sm">
|
||||
<FileTextIcon className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||
<span className="line-clamp-2">{prd.title}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{prd.completedStories}/{prd.totalStories} stories
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={prd.progress} className="h-2" />
|
||||
<div className="text-right text-xs font-medium">
|
||||
{prd.progress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
<span>Updated {prd.lastUpdated}</span>
|
||||
</div>
|
||||
|
||||
{/* Filename */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{prd.filename}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create PRD Form Modal */}
|
||||
{showCreateForm && (
|
||||
<Dialog open={showCreateForm} onOpenChange={setShowCreateForm}>
|
||||
<DialogContent className="max-w-4xl overflow-y-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New PRD</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="overflow-y-auto p-0.5"
|
||||
style={{
|
||||
maxHeight: '800px',
|
||||
}}
|
||||
>
|
||||
<CreatePRDForm onSubmit={handleCreatePRD} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { withI18n } from '@/lib/i18n/with-i18n';
|
||||
import { DatabaseIcon, FileTextIcon, ServerIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
export const metadata = {
|
||||
title: 'MCP Server',
|
||||
description:
|
||||
'MCP Server development interface for database exploration and PRD management',
|
||||
};
|
||||
|
||||
function McpServerPage() {
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
<div className={'flex h-screen flex-col overflow-hidden'}>
|
||||
<PageHeader
|
||||
displaySidebarTrigger={false}
|
||||
title={'MCP Server'}
|
||||
description={
|
||||
'Access MCP Server tools for database exploration and PRD management.'
|
||||
}
|
||||
/>
|
||||
|
||||
<PageBody className={'overflow-hidden'}>
|
||||
<div className={'flex h-full flex-1 flex-col p-6'}>
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="text-center">
|
||||
<ServerIcon className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
|
||||
<h2 className="mb-2 text-2xl font-bold">
|
||||
Welcome to MCP Server Tools
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl">
|
||||
Choose from the tools below to explore your database schema or
|
||||
manage your Product Requirements Documents. Use the sidebar
|
||||
navigation for quick access to specific tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tool Cards */}
|
||||
<div className="mx-auto grid max-w-4xl gap-6 md:grid-cols-2">
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<DatabaseIcon className="h-6 w-6" />
|
||||
Database Tools
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Explore database schemas, tables, functions, and enums
|
||||
through an intuitive interface.
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• Browse database schemas and their structure</li>
|
||||
<li>• Explore tables with columns and relationships</li>
|
||||
<li>
|
||||
• Discover database functions and their parameters
|
||||
</li>
|
||||
<li>• View enum types and their values</li>
|
||||
</ul>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/mcp-server/database">
|
||||
Open Database Tools
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<FileTextIcon className="h-6 w-6" />
|
||||
PRD Manager
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage Product Requirements Documents with user
|
||||
stories and progress tracking.
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• Create and edit PRDs with structured templates</li>
|
||||
<li>• Manage user stories with priority tracking</li>
|
||||
<li>• Track progress and project status</li>
|
||||
<li>• Export PRDs to markdown format</li>
|
||||
</ul>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/mcp-server/prd">Open PRD Manager</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(McpServerPage);
|
||||
@@ -1,25 +1,37 @@
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
import { EnvModeSelector } from '@/components/env-mode-selector';
|
||||
import { ServiceCard } from '@/components/status-tile';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { createConnectivityService } from './lib/connectivity-service';
|
||||
import { loadDashboardKitPrerequisites } from './lib/prerequisites-dashboard.loader';
|
||||
import { loadDashboardKitStatus } from './lib/status-dashboard.loader';
|
||||
|
||||
type DashboardPageProps = React.PropsWithChildren<{
|
||||
searchParams: Promise<{ mode?: EnvMode }>;
|
||||
}>;
|
||||
export default async function DashboardPage() {
|
||||
const [status, prerequisites] = await Promise.all([
|
||||
loadDashboardKitStatus(),
|
||||
loadDashboardKitPrerequisites(),
|
||||
]);
|
||||
|
||||
export default async function DashboardPage(props: DashboardPageProps) {
|
||||
const mode = (await props.searchParams).mode ?? 'development';
|
||||
const connectivityService = createConnectivityService(mode);
|
||||
const failedRequiredCount = prerequisites.prerequisites.filter(
|
||||
(item) => item.required && item.status === 'fail',
|
||||
).length;
|
||||
|
||||
const [supabaseStatus, supabaseAdminStatus, stripeStatus] = await Promise.all(
|
||||
[
|
||||
connectivityService.checkSupabaseConnectivity(),
|
||||
connectivityService.checkSupabaseAdminConnectivity(),
|
||||
connectivityService.checkStripeConnected(),
|
||||
],
|
||||
const warnCount = prerequisites.prerequisites.filter(
|
||||
(item) => item.status === 'warn',
|
||||
).length;
|
||||
|
||||
const failedRequired = prerequisites.prerequisites.filter(
|
||||
(item) => item.required && item.status === 'fail',
|
||||
);
|
||||
|
||||
const prerequisiteRemedies = Array.from(
|
||||
new Set(
|
||||
failedRequired.flatMap((item) => [
|
||||
...(item.remedies ?? []),
|
||||
...(item.install_command ? [item.install_command] : []),
|
||||
...(item.install_url ? [item.install_url] : []),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -27,17 +39,148 @@ export default async function DashboardPage(props: DashboardPageProps) {
|
||||
<PageHeader
|
||||
displaySidebarTrigger={false}
|
||||
title={'Dev Tool'}
|
||||
description={'Check the status of your Supabase and Stripe services'}
|
||||
>
|
||||
<EnvModeSelector mode={mode} />
|
||||
</PageHeader>
|
||||
description={'Kit MCP status for this workspace'}
|
||||
/>
|
||||
|
||||
<PageBody className={'space-y-8 py-2'}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<ServiceCard name={'Supabase API'} status={supabaseStatus} />
|
||||
<ServiceCard name={'Supabase Admin'} status={supabaseAdminStatus} />
|
||||
<ServiceCard name={'Stripe API'} status={stripeStatus} />
|
||||
<ServiceCard
|
||||
name={'Variant'}
|
||||
status={{
|
||||
status: 'success',
|
||||
message: `${status.variant} (${status.variant_family})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Runtime'}
|
||||
status={{
|
||||
status: 'success',
|
||||
message: `${status.framework} • Node ${status.node_version} • ${status.package_manager}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Dependencies'}
|
||||
status={{
|
||||
status: status.deps_installed ? 'success' : 'error',
|
||||
message: status.deps_installed
|
||||
? 'Dependencies installed'
|
||||
: 'node_modules not found',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Git'}
|
||||
status={{
|
||||
status:
|
||||
status.git_branch === 'unknown'
|
||||
? 'info'
|
||||
: status.git_clean
|
||||
? 'success'
|
||||
: 'warning',
|
||||
message: `${status.git_branch} (${status.git_clean ? 'clean' : 'dirty'}) • ${status.git_modified_files.length} modified • ${status.git_untracked_files.length} untracked`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Dev Server'}
|
||||
status={{
|
||||
status: status.services.app.running ? 'success' : 'error',
|
||||
message: status.services.app.running
|
||||
? `Running on port ${status.services.app.port}`
|
||||
: 'Not running',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Supabase'}
|
||||
status={{
|
||||
status: status.services.supabase.running ? 'success' : 'error',
|
||||
message: status.services.supabase.running
|
||||
? `Running${status.services.supabase.api_port ? ` (API ${status.services.supabase.api_port})` : ''}${status.services.supabase.studio_port ? ` (Studio ${status.services.supabase.studio_port})` : ''}`
|
||||
: 'Not running',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Merge Check'}
|
||||
status={{
|
||||
status:
|
||||
status.git_merge_check.has_conflicts === true
|
||||
? 'warning'
|
||||
: status.git_merge_check.detectable
|
||||
? 'success'
|
||||
: 'info',
|
||||
message: status.git_merge_check.detectable
|
||||
? status.git_merge_check.has_conflicts
|
||||
? `${status.git_merge_check.conflict_files.length} potential conflicts vs ${status.git_merge_check.target_branch}`
|
||||
: `No conflicts vs ${status.git_merge_check.target_branch}`
|
||||
: status.git_merge_check.message,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Prerequisites'}
|
||||
status={{
|
||||
status:
|
||||
prerequisites.overall === 'fail'
|
||||
? 'error'
|
||||
: prerequisites.overall === 'warn'
|
||||
? 'warning'
|
||||
: 'success',
|
||||
message:
|
||||
prerequisites.overall === 'fail'
|
||||
? `${failedRequiredCount} required tools missing/mismatched`
|
||||
: prerequisites.overall === 'warn'
|
||||
? `${warnCount} optional warnings`
|
||||
: 'All prerequisites satisfied',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{failedRequired.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prerequisites Details</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-4'}>
|
||||
<div className={'space-y-2'}>
|
||||
<p className={'text-sm font-medium'}>Missing or Mismatched</p>
|
||||
<ul
|
||||
className={
|
||||
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
|
||||
}
|
||||
>
|
||||
{failedRequired.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.name}
|
||||
{item.version
|
||||
? ` (installed ${item.version}, requires >= ${item.minimum_version})`
|
||||
: ' (not installed)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={'space-y-2'}>
|
||||
<p className={'text-sm font-medium'}>Remediation</p>
|
||||
<ul
|
||||
className={
|
||||
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
|
||||
}
|
||||
>
|
||||
{prerequisiteRemedies.map((remedy) => (
|
||||
<li key={remedy}>
|
||||
<code>{remedy}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Progress } from '@kit/ui/progress';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||
|
||||
import { UserStoryDisplay } from '../../../_components/user-story-display';
|
||||
import type { PRDData } from '../../../_lib/server/prd-page.loader';
|
||||
import { UserStoryDisplay } from '../../_components/user-story-display';
|
||||
import type { PRDData } from '../../_lib/server/prd-page.loader';
|
||||
|
||||
interface PRDDetailViewProps {
|
||||
filename: string;
|
||||
@@ -2,7 +2,7 @@ import { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { loadPRDPageData } from '../../_lib/server/prd-page.loader';
|
||||
import { loadPRDPageData } from '../_lib/server/prd-page.loader';
|
||||
import { PRDDetailView } from './_components/prd-detail-view';
|
||||
|
||||
interface PRDPageProps {
|
||||
@@ -79,7 +79,7 @@ export function PRDsListInterface({ initialPrds }: PRDsListInterfaceProps) {
|
||||
{filteredPrds.map((prd) => (
|
||||
<Link
|
||||
key={prd.filename}
|
||||
href={`/mcp-server/prds/${prd.filename}`}
|
||||
href={`/prds/${prd.filename}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||
import { PRDsListInterface } from './_components/prds-list-interface';
|
||||
import { loadPRDs } from './_lib/server/prd-loader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PRDs - MCP Server',
|
||||
@@ -33,27 +33,7 @@ import {
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { updateTranslationAction } from '../lib/server-actions';
|
||||
import type { TranslationData, Translations } from '../lib/translations-loader';
|
||||
|
||||
function flattenTranslations(
|
||||
obj: TranslationData,
|
||||
prefix = '',
|
||||
result: Record<string, string> = {},
|
||||
) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
result[newKey] = value;
|
||||
} else {
|
||||
flattenTranslations(value, newKey, result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type FlattenedTranslations = Record<string, Record<string, string>>;
|
||||
import type { Translations } from '../lib/translations-loader';
|
||||
|
||||
export function TranslationsComparison({
|
||||
translations,
|
||||
@@ -74,35 +54,24 @@ export function TranslationsComparison({
|
||||
[],
|
||||
);
|
||||
|
||||
const locales = Object.keys(translations);
|
||||
const baseLocale = locales[0]!;
|
||||
const namespaces = Object.keys(translations[baseLocale] || {});
|
||||
const { base_locale, locales, namespaces } = translations;
|
||||
|
||||
const [selectedLocales, setSelectedLocales] = useState<Set<string>>(
|
||||
new Set(locales),
|
||||
);
|
||||
|
||||
// Flatten translations for the selected namespace
|
||||
const flattenedTranslations: FlattenedTranslations = {};
|
||||
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(
|
||||
namespaces[0] as string,
|
||||
namespaces[0] ?? '',
|
||||
);
|
||||
|
||||
for (const locale of locales) {
|
||||
const namespaceData = translations[locale]?.[selectedNamespace];
|
||||
|
||||
if (namespaceData) {
|
||||
flattenedTranslations[locale] = flattenTranslations(namespaceData);
|
||||
} else {
|
||||
flattenedTranslations[locale] = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Get all unique keys across all translations
|
||||
const allKeys = Array.from(
|
||||
new Set(
|
||||
Object.values(flattenedTranslations).flatMap((data) => Object.keys(data)),
|
||||
locales.flatMap((locale) =>
|
||||
Object.keys(
|
||||
translations.translations[locale]?.[selectedNamespace] ?? {},
|
||||
),
|
||||
),
|
||||
),
|
||||
).sort();
|
||||
|
||||
@@ -143,7 +112,7 @@ export function TranslationsComparison({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [subject$]);
|
||||
|
||||
if (locales.length === 0) {
|
||||
if (locales.length === 0 || !base_locale) {
|
||||
return <div>No translations found</div>;
|
||||
}
|
||||
|
||||
@@ -228,12 +197,16 @@ export function TranslationsComparison({
|
||||
</TableCell>
|
||||
|
||||
{visibleLocales.map((locale) => {
|
||||
const translations = flattenedTranslations[locale] ?? {};
|
||||
const translationsForLocale =
|
||||
translations.translations[locale]?.[selectedNamespace] ??
|
||||
{};
|
||||
|
||||
const baseTranslations =
|
||||
flattenedTranslations[baseLocale] ?? {};
|
||||
translations.translations[base_locale]?.[
|
||||
selectedNamespace
|
||||
] ?? {};
|
||||
|
||||
const value = translations[key];
|
||||
const value = translationsForLocale[key];
|
||||
const baseValue = baseTranslations[key];
|
||||
const isMissing = !value;
|
||||
const isDifferent = value !== baseValue;
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:url';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
import {
|
||||
createKitTranslationsDeps,
|
||||
createKitTranslationsService,
|
||||
} from '@kit/mcp-server/translations';
|
||||
|
||||
const Schema = z.object({
|
||||
locale: z.string().min(1),
|
||||
namespace: z.string().min(1),
|
||||
@@ -20,40 +24,18 @@ const Schema = z.object({
|
||||
export async function updateTranslationAction(props: z.infer<typeof Schema>) {
|
||||
// Validate the input
|
||||
const { locale, namespace, key, value } = Schema.parse(props);
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
|
||||
const root = resolve(process.cwd(), '..');
|
||||
const filePath = `${root}apps/web/public/locales/${locale}/${namespace}.json`;
|
||||
const service = createKitTranslationsService(
|
||||
createKitTranslationsDeps(rootPath),
|
||||
);
|
||||
|
||||
try {
|
||||
// Read the current translations file
|
||||
const translationsFile = readFileSync(filePath, 'utf8');
|
||||
const translations = JSON.parse(translationsFile) as Record<string, any>;
|
||||
|
||||
// Update the nested key value
|
||||
const keys = key.split('.') as string[];
|
||||
let current = translations;
|
||||
|
||||
// Navigate through nested objects until the second-to-last key
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const currentKey = keys[i] as string;
|
||||
|
||||
if (!current[currentKey]) {
|
||||
current[currentKey] = {};
|
||||
}
|
||||
|
||||
current = current[currentKey];
|
||||
}
|
||||
|
||||
// Set the value at the final key
|
||||
const finalKey = keys[keys.length - 1] as string;
|
||||
current[finalKey] = value;
|
||||
|
||||
// Write the updated translations back to the file
|
||||
writeFileSync(filePath, JSON.stringify(translations, null, 2), 'utf8');
|
||||
const result = await service.update({ locale, namespace, key, value });
|
||||
|
||||
revalidatePath(`/translations`);
|
||||
|
||||
return { success: true };
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to update translation:', error);
|
||||
throw new Error('Failed to update translation');
|
||||
|
||||
@@ -1,50 +1,21 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type TranslationData = {
|
||||
[key: string]: string | TranslationData;
|
||||
};
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
import {
|
||||
createKitTranslationsDeps,
|
||||
createKitTranslationsService,
|
||||
} from '@kit/mcp-server/translations';
|
||||
|
||||
export type Translations = {
|
||||
[locale: string]: {
|
||||
[namespace: string]: TranslationData;
|
||||
};
|
||||
base_locale: string;
|
||||
locales: string[];
|
||||
namespaces: string[];
|
||||
translations: Record<string, Record<string, Record<string, string>>>;
|
||||
};
|
||||
|
||||
export async function loadTranslations() {
|
||||
const localesPath = join(process.cwd(), '../web/public/locales');
|
||||
const localesDirents = readdirSync(localesPath, { withFileTypes: true });
|
||||
|
||||
const locales = localesDirents
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name);
|
||||
|
||||
const translations: Translations = {};
|
||||
|
||||
for (const locale of locales) {
|
||||
translations[locale] = {};
|
||||
|
||||
const namespaces = readdirSync(join(localesPath, locale)).filter((file) =>
|
||||
file.endsWith('.json'),
|
||||
export async function loadTranslations(): Promise<Translations> {
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitTranslationsService(
|
||||
createKitTranslationsDeps(rootPath),
|
||||
);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const namespaceName = namespace.replace('.json', '');
|
||||
|
||||
try {
|
||||
const filePath = join(localesPath, locale, namespace);
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
|
||||
translations[locale][namespaceName] = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Warning: Translation file not found for locale "${locale}" and namespace "${namespaceName}"`,
|
||||
);
|
||||
|
||||
translations[locale][namespaceName] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return translations;
|
||||
return service.list();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { TranslationsComparison } from './components/translations-comparison';
|
||||
|
||||
@@ -1,471 +1,6 @@
|
||||
import 'server-only';
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { envVariables } from './env-variables-model';
|
||||
import {
|
||||
AppEnvState,
|
||||
EnvFileInfo,
|
||||
EnvMode,
|
||||
EnvVariableState,
|
||||
ScanOptions,
|
||||
} from './types';
|
||||
|
||||
// Define precedence order for each mode
|
||||
const ENV_FILE_PRECEDENCE: Record<EnvMode, string[]> = {
|
||||
development: [
|
||||
'.env',
|
||||
'.env.development',
|
||||
'.env.local',
|
||||
'.env.development.local',
|
||||
],
|
||||
production: [
|
||||
'.env',
|
||||
'.env.production',
|
||||
'.env.local',
|
||||
'.env.production.local',
|
||||
],
|
||||
};
|
||||
|
||||
function getSourcePrecedence(source: string, mode: EnvMode): number {
|
||||
return ENV_FILE_PRECEDENCE[mode].indexOf(source);
|
||||
}
|
||||
|
||||
export async function scanMonorepoEnv(
|
||||
options: ScanOptions,
|
||||
): Promise<EnvFileInfo[]> {
|
||||
const {
|
||||
rootDir = path.resolve(process.cwd(), '../..'),
|
||||
apps = ['web'],
|
||||
mode,
|
||||
} = options;
|
||||
|
||||
const envTypes = ENV_FILE_PRECEDENCE[mode];
|
||||
const appsDir = path.join(rootDir, 'apps');
|
||||
const results: EnvFileInfo[] = [];
|
||||
|
||||
try {
|
||||
const appDirs = await fs.readdir(appsDir);
|
||||
|
||||
for (const appName of appDirs) {
|
||||
if (apps.length > 0 && !apps.includes(appName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appDir = path.join(appsDir, appName);
|
||||
const stat = await fs.stat(appDir);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appInfo: EnvFileInfo = {
|
||||
appName,
|
||||
filePath: appDir,
|
||||
variables: [],
|
||||
};
|
||||
|
||||
for (const envType of envTypes) {
|
||||
const envPath = path.join(appDir, envType);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(envPath, 'utf-8');
|
||||
const vars = parseEnvFile(content, envType);
|
||||
|
||||
appInfo.variables.push(...vars);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(`Error reading ${envPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appInfo.variables.length > 0) {
|
||||
results.push(appInfo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scanning monorepo:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseEnvFile(content: string, source: string) {
|
||||
const variables: Array<{ key: string; value: string; source: string }> = [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.trim().startsWith('#') || !line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match KEY=VALUE pattern, handling quotes
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
const [, key = '', rawValue] = match;
|
||||
let value = rawValue ?? '';
|
||||
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Handle escaped quotes within the value
|
||||
value = value
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, '\\');
|
||||
|
||||
variables.push({
|
||||
key: key.trim(),
|
||||
value: value.trim(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
export function processEnvDefinitions(
|
||||
envInfo: EnvFileInfo,
|
||||
mode: EnvMode,
|
||||
): AppEnvState {
|
||||
const variableMap: Record<string, EnvVariableState> = {};
|
||||
|
||||
// First pass: Collect all definitions
|
||||
for (const variable of envInfo.variables) {
|
||||
if (!variable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = envVariables.find((v) => variable.key === v.name);
|
||||
|
||||
if (!variableMap[variable.key]) {
|
||||
variableMap[variable.key] = {
|
||||
key: variable.key,
|
||||
isVisible: true,
|
||||
definitions: [],
|
||||
effectiveValue: variable.value,
|
||||
effectiveSource: variable.source,
|
||||
isOverridden: false,
|
||||
category: model ? model.category : 'Custom',
|
||||
validation: {
|
||||
success: true,
|
||||
error: {
|
||||
issues: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const varState = variableMap[variable.key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
varState.definitions.push({
|
||||
key: variable.key,
|
||||
value: variable.value,
|
||||
source: variable.source,
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: Determine effective values and override status
|
||||
for (const key in variableMap) {
|
||||
const varState = variableMap[key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort definitions by mode-specific precedence
|
||||
varState.definitions.sort(
|
||||
(a, b) =>
|
||||
getSourcePrecedence(a.source, mode) -
|
||||
getSourcePrecedence(b.source, mode),
|
||||
);
|
||||
|
||||
if (varState.definitions.length > 1) {
|
||||
const lastDef = varState.definitions[varState.definitions.length - 1];
|
||||
|
||||
if (!lastDef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const highestPrecedence = getSourcePrecedence(lastDef.source, mode);
|
||||
|
||||
varState.isOverridden = true;
|
||||
varState.effectiveValue = lastDef.value;
|
||||
varState.effectiveSource = lastDef.source;
|
||||
|
||||
// Check for conflicts at highest precedence
|
||||
const conflictingDefs = varState.definitions.filter(
|
||||
(def) => getSourcePrecedence(def.source, mode) === highestPrecedence,
|
||||
);
|
||||
|
||||
if (conflictingDefs.length > 1) {
|
||||
varState.effectiveSource = `${varState.effectiveSource}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after computing the effective values, we can check for errors
|
||||
for (const key in variableMap) {
|
||||
const model = envVariables.find((v) => key === v.name);
|
||||
const varState = variableMap[key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let validation: {
|
||||
success: boolean;
|
||||
error: {
|
||||
issues: string[];
|
||||
};
|
||||
} = { success: true, error: { issues: [] } };
|
||||
|
||||
if (model) {
|
||||
const allVariables = Object.values(variableMap).reduce(
|
||||
(acc, variable) => {
|
||||
return {
|
||||
...acc,
|
||||
[variable.key]: variable.effectiveValue,
|
||||
};
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
// First check if it's required but missing
|
||||
if (model.required && !varState.effectiveValue) {
|
||||
validation = {
|
||||
success: false,
|
||||
error: {
|
||||
issues: [
|
||||
`This variable is required but missing from your environment files`,
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (model.contextualValidation) {
|
||||
// Then check contextual validation
|
||||
const dependenciesMet = model.contextualValidation.dependencies.some(
|
||||
(dep) => {
|
||||
const dependencyValue = allVariables[dep.variable] ?? '';
|
||||
|
||||
return dep.condition(dependencyValue, allVariables);
|
||||
},
|
||||
);
|
||||
|
||||
if (dependenciesMet) {
|
||||
// Only check for missing value or run validation if dependencies are met
|
||||
if (!varState.effectiveValue) {
|
||||
const dependencyErrors = model.contextualValidation.dependencies
|
||||
.map((dep) => {
|
||||
const dependencyValue = allVariables[dep.variable] ?? '';
|
||||
|
||||
const shouldValidate = dep.condition(
|
||||
dependencyValue,
|
||||
allVariables,
|
||||
);
|
||||
|
||||
if (shouldValidate) {
|
||||
const { success } = model.contextualValidation!.validate({
|
||||
value: varState.effectiveValue,
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dep.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((message): message is string => message !== null);
|
||||
|
||||
validation = {
|
||||
success: dependencyErrors.length === 0,
|
||||
error: {
|
||||
issues: dependencyErrors
|
||||
.map((message) => message)
|
||||
.filter((message) => !!message),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// If we have a value and dependencies are met, run contextual validation
|
||||
const result = model.contextualValidation.validate({
|
||||
value: varState.effectiveValue,
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
validation = {
|
||||
success: false,
|
||||
error: {
|
||||
issues: result.error.issues
|
||||
.map((issue) => issue.message)
|
||||
.filter((message) => !!message),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (model.validate && varState.effectiveValue) {
|
||||
// Only run regular validation if:
|
||||
// 1. There's no contextual validation
|
||||
// 2. There's a value to validate
|
||||
const result = model.validate({
|
||||
value: varState.effectiveValue,
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
validation = {
|
||||
success: false,
|
||||
error: {
|
||||
issues: result.error.issues
|
||||
.map((issue) => issue.message)
|
||||
.filter((message) => !!message),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
varState.validation = validation;
|
||||
}
|
||||
|
||||
// Final pass: Validate missing variables that are marked as required
|
||||
// or as having contextual validation
|
||||
for (const model of envVariables) {
|
||||
// If the variable exists in appState, use that
|
||||
const existingVar = variableMap[model.name];
|
||||
|
||||
if (existingVar) {
|
||||
// If the variable is already in the map, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
if (model.required || model.contextualValidation) {
|
||||
if (model.contextualValidation) {
|
||||
const allVariables = Object.values(variableMap).reduce(
|
||||
(acc, variable) => {
|
||||
return {
|
||||
...acc,
|
||||
[variable.key]: variable.effectiveValue,
|
||||
};
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const errors =
|
||||
model?.contextualValidation?.dependencies
|
||||
.map((dep) => {
|
||||
const dependencyValue = allVariables[dep.variable] ?? '';
|
||||
const shouldValidate = dep.condition(
|
||||
dependencyValue,
|
||||
allVariables,
|
||||
);
|
||||
|
||||
if (!shouldValidate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const effectiveValue = allVariables[dep.variable] ?? '';
|
||||
|
||||
const validation = model.contextualValidation!.validate({
|
||||
value: effectiveValue,
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (validation) {
|
||||
return [dep.message];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.flat() ?? ([] as string[]);
|
||||
|
||||
if (errors.length === 0) {
|
||||
continue;
|
||||
} else {
|
||||
variableMap[model.name] = {
|
||||
key: model.name,
|
||||
effectiveValue: '',
|
||||
effectiveSource: 'MISSING',
|
||||
isVisible: true,
|
||||
category: model.category,
|
||||
isOverridden: false,
|
||||
definitions: [],
|
||||
validation: {
|
||||
success: false,
|
||||
error: {
|
||||
issues: errors.map((error) => error),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't exist but is required or has contextual validation, create an empty state
|
||||
variableMap[model.name] = {
|
||||
key: model.name,
|
||||
effectiveValue: '',
|
||||
effectiveSource: 'MISSING',
|
||||
isVisible: true,
|
||||
category: model.category,
|
||||
isOverridden: false,
|
||||
definitions: [],
|
||||
validation: {
|
||||
success: false,
|
||||
error: {
|
||||
issues: [
|
||||
`This variable is required but missing from your environment files`,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appName: envInfo.appName,
|
||||
filePath: envInfo.filePath,
|
||||
mode,
|
||||
variables: variableMap,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnvState(
|
||||
options: ScanOptions,
|
||||
): Promise<AppEnvState[]> {
|
||||
const envInfos = await scanMonorepoEnv(options);
|
||||
return envInfos.map((info) => processEnvDefinitions(info, options.mode));
|
||||
}
|
||||
|
||||
export async function getVariable(key: string, mode: EnvMode) {
|
||||
// Get the processed environment state for all apps (you can limit to 'web' via options)
|
||||
const envStates = await getEnvState({ mode, apps: ['web'] });
|
||||
|
||||
// Find the state for the "web" app.
|
||||
const webState = envStates.find((state) => state.appName === 'web');
|
||||
|
||||
// Return the effectiveValue based on override status.
|
||||
return webState?.variables[key]?.effectiveValue ?? '';
|
||||
}
|
||||
export {
|
||||
getEnvState,
|
||||
getVariable,
|
||||
processEnvDefinitions,
|
||||
scanMonorepoEnv,
|
||||
} from '@kit/mcp-server/env';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,83 +2,35 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { envVariables } from '@/app/variables/lib/env-variables-model';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:url';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
createKitEnvDeps,
|
||||
createKitEnvService,
|
||||
findWorkspaceRoot,
|
||||
} from '@kit/mcp-server/env';
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string(),
|
||||
mode: z.enum(['development', 'production']),
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the environment variable in the specified file.
|
||||
* @param props
|
||||
*/
|
||||
export async function updateEnvironmentVariableAction(
|
||||
props: z.infer<typeof Schema>,
|
||||
) {
|
||||
// Validate the input
|
||||
const { name, mode, value } = Schema.parse(props);
|
||||
const root = resolve(process.cwd(), '..');
|
||||
const model = envVariables.find((item) => item.name === name);
|
||||
|
||||
// Determine the source file based on the mode
|
||||
const source = (() => {
|
||||
const isSecret = model?.secret ?? true;
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEnvService(createKitEnvDeps(rootPath));
|
||||
|
||||
switch (mode) {
|
||||
case 'development':
|
||||
if (isSecret) {
|
||||
return '.env.local';
|
||||
} else {
|
||||
return '.env.development';
|
||||
}
|
||||
|
||||
case 'production':
|
||||
if (isSecret) {
|
||||
return '.env.production.local';
|
||||
} else {
|
||||
return '.env.production';
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid mode: ${mode}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// check file exists, if not, create it
|
||||
const filePath = `${root}/apps/web/${source}`;
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
writeFileSync(filePath, '', 'utf8');
|
||||
}
|
||||
|
||||
const sourceEnvFile = readFileSync(`${root}apps/web/${source}`, 'utf8');
|
||||
|
||||
let updatedEnvFile = '';
|
||||
const isInSourceFile = sourceEnvFile.includes(name);
|
||||
const isCommentedOut = sourceEnvFile.includes(`#${name}=`);
|
||||
|
||||
if (isInSourceFile && !isCommentedOut) {
|
||||
updatedEnvFile = sourceEnvFile.replace(
|
||||
new RegExp(`^${name}=.*`, 'm'),
|
||||
`${name}=${value}`,
|
||||
);
|
||||
} else {
|
||||
// if the key does not exist, append it to the end of the file
|
||||
updatedEnvFile = `${sourceEnvFile}\n${name}=${value}`;
|
||||
}
|
||||
|
||||
// write the updated content back to the file
|
||||
writeFileSync(`${root}/apps/web/${source}`, updatedEnvFile, 'utf8');
|
||||
|
||||
revalidatePath(`/variables`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Updated ${name} in "${source}"`,
|
||||
};
|
||||
const result = await service.update({
|
||||
key: name,
|
||||
value,
|
||||
mode,
|
||||
});
|
||||
|
||||
revalidatePath('/variables');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import {
|
||||
processEnvDefinitions,
|
||||
scanMonorepoEnv,
|
||||
} from '@/app/variables/lib/env-scanner';
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import {
|
||||
createKitEnvDeps,
|
||||
createKitEnvService,
|
||||
findWorkspaceRoot,
|
||||
} from '@kit/mcp-server/env';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager';
|
||||
@@ -21,7 +21,7 @@ export const metadata = {
|
||||
|
||||
export default function VariablesPage({ searchParams }: VariablesPageProps) {
|
||||
const { mode = 'development' } = use(searchParams);
|
||||
const apps = use(scanMonorepoEnv({ mode }));
|
||||
const apps = use(loadEnvStates(mode));
|
||||
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
@@ -36,19 +36,18 @@ export default function VariablesPage({ searchParams }: VariablesPageProps) {
|
||||
|
||||
<PageBody className={'overflow-hidden'}>
|
||||
<div className={'flex h-full flex-1 flex-col space-y-4'}>
|
||||
{apps.map((app) => {
|
||||
const appEnvState = processEnvDefinitions(app, mode);
|
||||
|
||||
return (
|
||||
<AppEnvironmentVariablesManager
|
||||
key={app.appName}
|
||||
state={appEnvState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{apps.map((app) => (
|
||||
<AppEnvironmentVariablesManager key={app.appName} state={app} />
|
||||
))}
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEnvStates(mode: EnvMode) {
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEnvService(createKitEnvDeps(rootPath));
|
||||
return service.getAppState(mode);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
LanguagesIcon,
|
||||
LayoutDashboardIcon,
|
||||
MailIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -54,22 +53,16 @@ const routes = [
|
||||
path: '/translations',
|
||||
Icon: LanguagesIcon,
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
Icon: ServerIcon,
|
||||
children: [
|
||||
{
|
||||
label: 'Database',
|
||||
path: '/mcp-server/database',
|
||||
path: '/database',
|
||||
Icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
label: 'PRD Manager',
|
||||
path: '/mcp-server/prds',
|
||||
path: '/prds',
|
||||
Icon: FileTextIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function DevToolSidebar({
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Card, CardContent } from '@kit/ui/card';
|
||||
|
||||
export const ServiceStatus = {
|
||||
CHECKING: 'checking',
|
||||
SUCCESS: 'success',
|
||||
WARNING: 'warning',
|
||||
INFO: 'info',
|
||||
ERROR: 'error',
|
||||
} as const;
|
||||
|
||||
@@ -16,6 +17,8 @@ type ServiceStatusType = (typeof ServiceStatus)[keyof typeof ServiceStatus];
|
||||
const StatusIcons = {
|
||||
[ServiceStatus.CHECKING]: <AlertCircle className="h-6 w-6 text-yellow-500" />,
|
||||
[ServiceStatus.SUCCESS]: <CheckCircle2 className="h-6 w-6 text-green-500" />,
|
||||
[ServiceStatus.WARNING]: <AlertCircle className="h-6 w-6 text-amber-500" />,
|
||||
[ServiceStatus.INFO]: <AlertCircle className="h-6 w-6 text-blue-500" />,
|
||||
[ServiceStatus.ERROR]: <XCircle className="h-6 w-6 text-red-500" />,
|
||||
};
|
||||
|
||||
@@ -30,7 +33,7 @@ interface ServiceCardProps {
|
||||
export const ServiceCard = ({ name, status }: ServiceCardProps) => {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"author": "Makerkit",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"dotenv": "17.2.4",
|
||||
|
||||
@@ -22,6 +22,7 @@ app/
|
||||
|
||||
For specialized implementation:
|
||||
- `/feature-builder` - End-to-end feature implementation
|
||||
- `/service-builder` - Server side services
|
||||
- `/server-action-builder` - Server actions
|
||||
- `/forms-builder` - Forms with validation
|
||||
- `/navigation-config` - Adding routes and menu items
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- **ALWAYS** validate admin status before operations
|
||||
- **NEVER** bypass authentication or authorization
|
||||
- **ALWAYS** audit admin operations with logging
|
||||
- **ALWAYS** use `adminAction` to wrap admin actions @packages/features/admin/src/lib/server/utils/admin-action.ts
|
||||
|
||||
## Page Structure
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ErrorPageContent({
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={backLink}>
|
||||
<ArrowLeft className={'h-4 w-4 mr-1'} />
|
||||
<ArrowLeft className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={backLabel} />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -77,7 +77,7 @@ export function ErrorPageContent({
|
||||
|
||||
<Button asChild variant={'ghost'}>
|
||||
<Link href={'/contact'}>
|
||||
<MessageCircleQuestion className={'h-4 w-4 mr-1'} />
|
||||
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={contactLabel} />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -34,7 +34,7 @@ const config = {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
serverExternalPackages: ['pino', 'thread-stream'],
|
||||
serverExternalPackages: [],
|
||||
// needed for supporting dynamic imports for local content
|
||||
outputFileTracingIncludes: {
|
||||
'/*': ['./content/**/*'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-supabase-saas-kit-turbo",
|
||||
"version": "2.23.14",
|
||||
"version": "2.24.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"@turbo/gen": "^2.7.6",
|
||||
"cross-env": "^10.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"turbo": "2.7.6",
|
||||
"turbo": "2.8.5",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
22
packages/email-templates/AGENTS.md
Normal file
22
packages/email-templates/AGENTS.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Email Templates Instructions
|
||||
|
||||
This package owns transactional email templates and renderers using React Email.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss it.
|
||||
2. New email renderer must be exported from `src/index.ts`.
|
||||
3. Renderer contract: async function returning `{ html, subject }`.
|
||||
4. i18n namespace must match locale filename in `src/locales/<lang>/<namespace>.json`.
|
||||
5. Reuse shared primitives in `src/components/*` for layout/style consistency.
|
||||
6. Include one clear CTA and a plain URL fallback in body copy.
|
||||
7. Keep subject/body concise, action-first, non-spammy.
|
||||
|
||||
## When adding a new email
|
||||
|
||||
1. Add template in `src/emails/*.email.tsx`.
|
||||
2. Add locale file in `src/locales/en/*-email.json` if template uses i18n.
|
||||
3. Export template renderer from `src/index.ts`.
|
||||
4. Add renderer to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`).
|
||||
|
||||
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering will miss it.
|
||||
1
packages/email-templates/CLAUDE.md
Normal file
1
packages/email-templates/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./registry": "./src/registry.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "catalog:"
|
||||
@@ -20,7 +21,10 @@
|
||||
"@kit/i18n": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "catalog:"
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
39
packages/email-templates/src/registry.ts
Normal file
39
packages/email-templates/src/registry.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { renderAccountDeleteEmail } from './emails/account-delete.email';
|
||||
import { renderInviteEmail } from './emails/invite.email';
|
||||
import { renderOtpEmail } from './emails/otp.email';
|
||||
|
||||
/**
|
||||
* Registry of email template renderers.
|
||||
*
|
||||
* This is used to render email templates dynamically. Ex. list all available email templates in the MCP server.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const { html, subject } = await renderAccountDeleteEmail({
|
||||
* userDisplayName: 'John Doe',
|
||||
* productName: 'My SaaS App',
|
||||
* });
|
||||
*
|
||||
* await mailer.sendEmail({
|
||||
* to: 'user@example.com',
|
||||
* from: 'noreply@yourdomain.com',
|
||||
* subject,
|
||||
* html,
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const { html, subject } = await renderAccountDeleteEmail({
|
||||
* userDisplayName: 'John Doe',
|
||||
* productName: 'My SaaS App',
|
||||
* });
|
||||
*
|
||||
*/
|
||||
export const EMAIL_TEMPLATE_RENDERERS = {
|
||||
'account-delete-email': renderAccountDeleteEmail,
|
||||
'invite-email': renderInviteEmail,
|
||||
'otp-email': renderOtpEmail,
|
||||
};
|
||||
|
||||
export type EmailTemplateRenderer =
|
||||
(typeof EMAIL_TEMPLATE_RENDERERS)[keyof typeof EMAIL_TEMPLATE_RENDERERS];
|
||||
@@ -22,6 +22,7 @@ export function createI18nSettings({
|
||||
supportedLngs: languages,
|
||||
fallbackLng: languages[0],
|
||||
detection: undefined,
|
||||
showSupportNotice: false,
|
||||
lng,
|
||||
preload: false as const,
|
||||
lowerCaseLng: true as const,
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function initializeI18nClient(
|
||||
.init(
|
||||
{
|
||||
...settings,
|
||||
showSupportNotice: false,
|
||||
detection: {
|
||||
order: ['cookie', 'htmlTag', 'navigator'],
|
||||
caches: ['cookie'],
|
||||
|
||||
16
packages/mcp-server/AGENTS.md
Normal file
16
packages/mcp-server/AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# MCP Server Instructions
|
||||
|
||||
This package owns Makerkit MCP tool/resource registration and adapters.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. Use service pattern: keep business/domain logic in `*.service.ts`, not MCP handlers.
|
||||
2. `index.ts` in each tool folder is adapter only: parse input, call service, map output/errors.
|
||||
3. Inject deps via `create*Deps` + `create*Service`; avoid hidden globals/singletons.
|
||||
4. Keep schemas in `schema.ts`; validate all tool input with zod before service call.
|
||||
5. Export public registration + service factory/types from each tool `index.ts`.
|
||||
6. Add/maintain unit tests for service behavior and tool adapter behavior.
|
||||
7. Register new tools/resources in `src/index.ts`.
|
||||
8. Keep tool responses structured + stable; avoid breaking output shapes.
|
||||
|
||||
Service pattern is required to decouple logic from MCP server transport and keep logic testable/reusable.
|
||||
1
packages/mcp-server/CLAUDE.md
Normal file
1
packages/mcp-server/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "@kit/mcp-server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "./build/index.js",
|
||||
"module": true,
|
||||
"type": "module",
|
||||
"main": "./build/index.cjs",
|
||||
"bin": {
|
||||
"makerkit-mcp-server": "./build/index.js"
|
||||
"makerkit-mcp-server": "./build/index.cjs"
|
||||
},
|
||||
"exports": {
|
||||
"./database": "./src/tools/database.ts",
|
||||
@@ -13,28 +13,37 @@
|
||||
"./migrations": "./src/tools/migrations.ts",
|
||||
"./prd-manager": "./src/tools/prd-manager.ts",
|
||||
"./prompts": "./src/tools/prompts.ts",
|
||||
"./scripts": "./src/tools/scripts.ts"
|
||||
"./scripts": "./src/tools/scripts.ts",
|
||||
"./status": "./src/tools/status/index.ts",
|
||||
"./prerequisites": "./src/tools/prerequisites/index.ts",
|
||||
"./env": "./src/tools/env/index.ts",
|
||||
"./env/model": "./src/tools/env/model.ts",
|
||||
"./env/types": "./src/tools/env/types.ts",
|
||||
"./dev": "./src/tools/dev/index.ts",
|
||||
"./db": "./src/tools/db/index.ts",
|
||||
"./emails": "./src/tools/emails/index.ts",
|
||||
"./translations": "./src/tools/translations/index.ts",
|
||||
"./run-checks": "./src/tools/run-checks/index.ts",
|
||||
"./deps-upgrade-advisor": "./src/tools/deps-upgrade-advisor/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||
"build": "tsc",
|
||||
"build:watch": "tsc --watch"
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"@types/node": "catalog:",
|
||||
"postgres": "3.4.8",
|
||||
"tsup": "catalog:",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
"prettier": "@kit/prettier-config"
|
||||
}
|
||||
|
||||
@@ -6,10 +6,20 @@ import {
|
||||
registerDatabaseResources,
|
||||
registerDatabaseTools,
|
||||
} from './tools/database';
|
||||
import { registerKitDbTools } from './tools/db/index';
|
||||
import { registerDepsUpgradeAdvisorTool } from './tools/deps-upgrade-advisor/index';
|
||||
import { registerKitDevTools } from './tools/dev/index';
|
||||
import { registerKitEmailTemplatesTools } from './tools/emails/index';
|
||||
import { registerKitEnvTools } from './tools/env/index';
|
||||
import { registerKitEmailsTools } from './tools/mailbox/index';
|
||||
import { registerGetMigrationsTools } from './tools/migrations';
|
||||
import { registerPRDTools } from './tools/prd-manager';
|
||||
import { registerKitPrerequisitesTool } from './tools/prerequisites/index';
|
||||
import { registerPromptsSystem } from './tools/prompts';
|
||||
import { registerRunChecksTool } from './tools/run-checks/index';
|
||||
import { registerScriptsTools } from './tools/scripts';
|
||||
import { registerKitStatusTool } from './tools/status/index';
|
||||
import { registerKitTranslationsTools } from './tools/translations/index';
|
||||
|
||||
async function main() {
|
||||
// Create server instance
|
||||
@@ -21,10 +31,20 @@ async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
registerGetMigrationsTools(server);
|
||||
registerKitStatusTool(server);
|
||||
registerKitPrerequisitesTool(server);
|
||||
registerKitEnvTools(server);
|
||||
registerKitDevTools(server);
|
||||
registerKitDbTools(server);
|
||||
registerKitEmailsTools(server);
|
||||
registerKitEmailTemplatesTools(server);
|
||||
registerKitTranslationsTools(server);
|
||||
registerDatabaseTools(server);
|
||||
registerDatabaseResources(server);
|
||||
registerComponentsTools(server);
|
||||
registerScriptsTools(server);
|
||||
registerRunChecksTool(server);
|
||||
registerDepsUpgradeAdvisorTool(server);
|
||||
registerPRDTools(server);
|
||||
registerPromptsSystem(server);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
interface ComponentInfo {
|
||||
name: string;
|
||||
@@ -345,9 +345,12 @@ export function registerComponentsTools(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetComponentsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_components',
|
||||
{
|
||||
description:
|
||||
'Get all available UI components from the @kit/ui package with descriptions',
|
||||
},
|
||||
async () => {
|
||||
const components = await ComponentsTool.getComponents();
|
||||
|
||||
@@ -371,14 +374,16 @@ function createGetComponentsTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetComponentContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_component_content',
|
||||
'Get the source code content of a specific UI component',
|
||||
{
|
||||
description: 'Get the source code content of a specific UI component',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
componentName: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await ComponentsTool.getComponentContent(
|
||||
state.componentName,
|
||||
@@ -397,14 +402,17 @@ function createGetComponentContentTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createComponentsSearchTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'components_search',
|
||||
'Search UI components by keyword in name, description, or category',
|
||||
{
|
||||
description:
|
||||
'Search UI components by keyword in name, description, or category',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const components = await ComponentsTool.searchComponents(state.query);
|
||||
|
||||
@@ -439,14 +447,17 @@ function createComponentsSearchTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetComponentPropsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_component_props',
|
||||
'Extract component props, interfaces, and variants from a UI component',
|
||||
{
|
||||
description:
|
||||
'Extract component props, interfaces, and variants from a UI component',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
componentName: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const propsInfo = await ComponentsTool.getComponentProps(
|
||||
state.componentName,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
import { z } from 'zod';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ||
|
||||
@@ -1135,9 +1135,12 @@ export function registerDatabaseResources(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetSchemaFilesTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_schema_files',
|
||||
{
|
||||
description:
|
||||
'🔥 DATABASE SCHEMA FILES (SOURCE OF TRUTH - ALWAYS CURRENT) - Use these over migrations!',
|
||||
},
|
||||
async () => {
|
||||
const schemaFiles = await DatabaseTool.getSchemaFiles();
|
||||
|
||||
@@ -1168,9 +1171,12 @@ function createGetSchemaFilesTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetFunctionsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_database_functions',
|
||||
{
|
||||
description:
|
||||
'Get all database functions with descriptions and usage guidance',
|
||||
},
|
||||
async () => {
|
||||
const functions = await DatabaseTool.getFunctions();
|
||||
|
||||
@@ -1202,14 +1208,17 @@ function createGetFunctionsTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetFunctionDetailsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_function_details',
|
||||
'Get detailed information about a specific database function',
|
||||
{
|
||||
description:
|
||||
'Get detailed information about a specific database function',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
functionName: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const func = await DatabaseTool.getFunctionDetails(state.functionName);
|
||||
|
||||
@@ -1253,14 +1262,16 @@ Source File: ${func.sourceFile}`,
|
||||
}
|
||||
|
||||
function createSearchFunctionsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'search_database_functions',
|
||||
'Search database functions by name, description, or purpose',
|
||||
{
|
||||
description: 'Search database functions by name, description, or purpose',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const functions = await DatabaseTool.searchFunctions(state.query);
|
||||
|
||||
@@ -1295,14 +1306,17 @@ function createSearchFunctionsTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetSchemaContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_schema_content',
|
||||
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
|
||||
{
|
||||
description:
|
||||
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
fileName: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await DatabaseTool.getSchemaContent(state.fileName);
|
||||
|
||||
@@ -1319,14 +1333,17 @@ function createGetSchemaContentTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetSchemasByTopicTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_schemas_by_topic',
|
||||
'🎯 Find schema files by topic (accounts, auth, billing, permissions, etc.) - Fastest way to find relevant schemas',
|
||||
{
|
||||
description:
|
||||
'🎯 Find schema files by topic (accounts, auth, billing, permissions, etc.) - Fastest way to find relevant schemas',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
topic: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const schemas = await DatabaseTool.getSchemasByTopic(state.topic);
|
||||
|
||||
@@ -1368,14 +1385,17 @@ function createGetSchemasByTopicTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetSchemaBySectionTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_schema_by_section',
|
||||
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
|
||||
{
|
||||
description:
|
||||
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
section: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const schema = await DatabaseTool.getSchemaBySection(state.section);
|
||||
|
||||
@@ -1414,9 +1434,12 @@ function createGetSchemaBySectionTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createDatabaseSummaryTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_database_summary',
|
||||
{
|
||||
description:
|
||||
'📊 Get comprehensive database overview with tables, enums, and functions',
|
||||
},
|
||||
async () => {
|
||||
const tables = await DatabaseTool.getAllProjectTables();
|
||||
const enums = await DatabaseTool.getAllEnums();
|
||||
@@ -1468,9 +1491,11 @@ function createDatabaseSummaryTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createDatabaseTablesListTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_database_tables',
|
||||
'📋 Get list of all project-defined database tables',
|
||||
{
|
||||
description: '📋 Get list of all project-defined database tables',
|
||||
},
|
||||
async () => {
|
||||
const tables = await DatabaseTool.getAllProjectTables();
|
||||
|
||||
@@ -1487,15 +1512,18 @@ function createDatabaseTablesListTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetTableInfoTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_table_info',
|
||||
'🗂️ Get detailed table schema with columns, foreign keys, and indexes',
|
||||
{
|
||||
description:
|
||||
'🗂️ Get detailed table schema with columns, foreign keys, and indexes',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
schema: z.string().default('public'),
|
||||
tableName: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
try {
|
||||
const tableInfo = await DatabaseTool.getTableInfo(
|
||||
@@ -1526,14 +1554,16 @@ function createGetTableInfoTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetEnumInfoTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_enum_info',
|
||||
'🏷️ Get enum type definition with all possible values',
|
||||
{
|
||||
description: '🏷️ Get enum type definition with all possible values',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
enumName: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
try {
|
||||
const enums = await DatabaseTool.getAllEnums();
|
||||
@@ -1573,9 +1603,11 @@ function createGetEnumInfoTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetAllEnumsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_all_enums',
|
||||
'🏷️ Get all enum types and their values',
|
||||
{
|
||||
description: '🏷️ Get all enum types and their values',
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const enums = await DatabaseTool.getAllEnums();
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type KitDbServiceDeps, createKitDbService } from '../kit-db.service';
|
||||
|
||||
function createDeps(
|
||||
overrides: Partial<KitDbServiceDeps> = {},
|
||||
): KitDbServiceDeps {
|
||||
return {
|
||||
rootPath: '/repo',
|
||||
async resolveVariantContext() {
|
||||
return {
|
||||
variant: 'next-supabase',
|
||||
variantFamily: 'supabase',
|
||||
tool: 'supabase',
|
||||
};
|
||||
},
|
||||
async executeCommand() {
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
async isPortOpen() {
|
||||
return false;
|
||||
},
|
||||
async fileExists() {
|
||||
return false;
|
||||
},
|
||||
async readdir() {
|
||||
return [];
|
||||
},
|
||||
async readJsonFile() {
|
||||
return {};
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KitDbService.status', () => {
|
||||
it('reports pending migrations when CLI output is unavailable', async () => {
|
||||
const deps = createDeps({
|
||||
async fileExists(path: string) {
|
||||
return path.includes('supabase/migrations');
|
||||
},
|
||||
async readdir() {
|
||||
return ['20260101010101_create_table.sql'];
|
||||
},
|
||||
async executeCommand() {
|
||||
throw new Error('supabase missing');
|
||||
},
|
||||
});
|
||||
|
||||
const service = createKitDbService(deps);
|
||||
const result = await service.status();
|
||||
|
||||
expect(result.migrations.pending).toBe(1);
|
||||
expect(result.migrations.pending_names).toEqual([
|
||||
'20260101010101_create_table',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats local migrations as applied when connected', async () => {
|
||||
const deps = createDeps({
|
||||
async fileExists(path: string) {
|
||||
return path.includes('supabase/migrations');
|
||||
},
|
||||
async readdir() {
|
||||
return ['20260101010101_create_table.sql'];
|
||||
},
|
||||
async executeCommand() {
|
||||
throw new Error('supabase missing');
|
||||
},
|
||||
async isPortOpen() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const service = createKitDbService(deps);
|
||||
const result = await service.status();
|
||||
|
||||
expect(result.migrations.applied).toBe(1);
|
||||
expect(result.migrations.pending).toBe(0);
|
||||
});
|
||||
|
||||
it('parses supabase migrations list output', async () => {
|
||||
const deps = createDeps({
|
||||
async executeCommand(command, args) {
|
||||
if (command === 'supabase' && args.join(' ') === 'migrations list') {
|
||||
return {
|
||||
stdout:
|
||||
'20260101010101_create_table | applied\n20260202020202_add_billing | not applied\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const service = createKitDbService(deps);
|
||||
const result = await service.status();
|
||||
|
||||
expect(result.migrations.applied).toBe(1);
|
||||
expect(result.migrations.pending).toBe(1);
|
||||
expect(result.migrations.pending_names).toEqual([
|
||||
'20260202020202_add_billing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps id/name columns to local migration names', async () => {
|
||||
const deps = createDeps({
|
||||
async fileExists(path: string) {
|
||||
return path.includes('supabase/migrations');
|
||||
},
|
||||
async readdir() {
|
||||
return ['20240319163440_roles-seed.sql'];
|
||||
},
|
||||
async executeCommand(command, args) {
|
||||
if (command === 'supabase' && args.join(' ') === 'migrations list') {
|
||||
return {
|
||||
stdout:
|
||||
'20240319163440 | roles-seed | Applied\n20240401010101 | add-billing | Pending\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const service = createKitDbService(deps);
|
||||
const result = await service.status();
|
||||
|
||||
expect(result.migrations.applied).toBe(1);
|
||||
expect(result.migrations.pending).toBe(1);
|
||||
expect(result.migrations.pending_names).toEqual([
|
||||
'20240401010101_add-billing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats id/name list with no status as applied', async () => {
|
||||
const deps = createDeps({
|
||||
async fileExists(path: string) {
|
||||
return path.includes('supabase/migrations');
|
||||
},
|
||||
async readdir() {
|
||||
return [
|
||||
'20240319163440_roles-seed.sql',
|
||||
'20240401010101_add-billing.sql',
|
||||
];
|
||||
},
|
||||
async executeCommand(command, args) {
|
||||
if (command === 'supabase' && args.join(' ') === 'migrations list') {
|
||||
return {
|
||||
stdout:
|
||||
'20240319163440 | roles-seed\n20240401010101 | add-billing\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const service = createKitDbService(deps);
|
||||
const result = await service.status();
|
||||
|
||||
expect(result.migrations.applied).toBe(2);
|
||||
expect(result.migrations.pending).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KitDbService.migrate', () => {
|
||||
it('throws when target is not latest', async () => {
|
||||
const service = createKitDbService(createDeps());
|
||||
|
||||
await expect(
|
||||
service.migrate({ target: '20260101010101_create_table' }),
|
||||
).rejects.toThrow(/target "latest"/);
|
||||
});
|
||||
|
||||
it('returns applied migrations from pending list', async () => {
|
||||
const deps = createDeps({
|
||||
async fileExists(path: string) {
|
||||
return path.includes('supabase/migrations');
|
||||
},
|
||||
async readdir() {
|
||||
return ['20260101010101_create_table.sql'];
|
||||
},
|
||||
async executeCommand(command, args) {
|
||||
if (command === 'supabase' && args.join(' ') === 'migrations list') {
|
||||
return {
|
||||
stdout: '20260101010101_create_table | pending\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const service = createKitDbService(deps);
|
||||
const result = await service.migrate({ target: 'latest' });
|
||||
|
||||
expect(result.applied).toEqual(['20260101010101_create_table']);
|
||||
expect(result.total_applied).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KitDbService.reset', () => {
|
||||
it('requires confirm true', async () => {
|
||||
const service = createKitDbService(createDeps());
|
||||
|
||||
await expect(service.reset({ confirm: false })).rejects.toThrow(
|
||||
/confirm: true/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KitDbService.seed', () => {
|
||||
it('uses db:seed script when available', async () => {
|
||||
const exec = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
}));
|
||||
|
||||
const deps = createDeps({
|
||||
async readJsonFile() {
|
||||
return { scripts: { 'db:seed': 'tsx scripts/seed.ts' } };
|
||||
},
|
||||
async executeCommand(command, args) {
|
||||
return exec(command, args);
|
||||
},
|
||||
});
|
||||
|
||||
const service = createKitDbService(deps);
|
||||
await service.seed();
|
||||
|
||||
expect(exec).toHaveBeenCalledWith('pnpm', [
|
||||
'--filter',
|
||||
'web',
|
||||
'run',
|
||||
'db:seed',
|
||||
]);
|
||||
});
|
||||
});
|
||||
365
packages/mcp-server/src/tools/db/index.ts
Normal file
365
packages/mcp-server/src/tools/db/index.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile, readdir } from 'node:fs/promises';
|
||||
import { Socket } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { type KitDbServiceDeps, createKitDbService } from './kit-db.service';
|
||||
import {
|
||||
KitDbMigrateInputSchema,
|
||||
KitDbMigrateOutputSchema,
|
||||
KitDbResetInputSchema,
|
||||
KitDbResetOutputSchema,
|
||||
KitDbSeedInputSchema,
|
||||
KitDbSeedOutputSchema,
|
||||
KitDbStatusInputSchema,
|
||||
KitDbStatusOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type TextContent = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function registerKitDbTools(server: McpServer) {
|
||||
const service = createKitDbService(createKitDbDeps());
|
||||
|
||||
server.registerTool(
|
||||
'kit_db_status',
|
||||
{
|
||||
description: 'Check database connectivity and migrations state',
|
||||
inputSchema: KitDbStatusInputSchema,
|
||||
outputSchema: KitDbStatusOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
KitDbStatusInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const result = await service.status();
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_db_status', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_db_migrate',
|
||||
{
|
||||
description: 'Apply pending database migrations',
|
||||
inputSchema: KitDbMigrateInputSchema,
|
||||
outputSchema: KitDbMigrateOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitDbMigrateInputSchema.parse(input);
|
||||
const result = await service.migrate(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_db_migrate', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_db_seed',
|
||||
{
|
||||
description: 'Run database seed scripts',
|
||||
inputSchema: KitDbSeedInputSchema,
|
||||
outputSchema: KitDbSeedOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
KitDbSeedInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const result = await service.seed();
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_db_seed', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_db_reset',
|
||||
{
|
||||
description: 'Reset the database after confirmation',
|
||||
inputSchema: KitDbResetInputSchema,
|
||||
outputSchema: KitDbResetOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitDbResetInputSchema.parse(input);
|
||||
const result = await service.reset(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_db_reset', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createKitDbDeps(rootPath = process.cwd()): KitDbServiceDeps {
|
||||
return {
|
||||
rootPath,
|
||||
async resolveVariantContext() {
|
||||
const configuredVariant = await readConfiguredVariant(rootPath);
|
||||
if (configuredVariant) {
|
||||
return mapVariant(configuredVariant);
|
||||
}
|
||||
|
||||
if (await pathExists(join(rootPath, 'apps', 'web', 'supabase'))) {
|
||||
return mapVariant('next-supabase');
|
||||
}
|
||||
|
||||
const webPackage = await readJsonIfPresent(
|
||||
join(rootPath, 'apps', 'web', 'package.json'),
|
||||
);
|
||||
const dependencies = {
|
||||
...(webPackage?.dependencies ?? {}),
|
||||
...(webPackage?.devDependencies ?? {}),
|
||||
} as Record<string, unknown>;
|
||||
|
||||
if ('prisma' in dependencies || '@prisma/client' in dependencies) {
|
||||
return mapVariant('next-prisma');
|
||||
}
|
||||
|
||||
if ('drizzle-kit' in dependencies || 'drizzle-orm' in dependencies) {
|
||||
return mapVariant('next-drizzle');
|
||||
}
|
||||
|
||||
return mapVariant('next-supabase');
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await executeWithFallback(rootPath, command, args);
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
async isPortOpen(port: number) {
|
||||
return checkPort(port);
|
||||
},
|
||||
async fileExists(path: string) {
|
||||
return pathExists(path);
|
||||
},
|
||||
async readdir(path: string) {
|
||||
return readdir(path);
|
||||
},
|
||||
async readJsonFile(path: string) {
|
||||
const raw = await readFile(path, 'utf8');
|
||||
return JSON.parse(raw) as unknown;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapVariant(variant: string) {
|
||||
if (variant === 'next-prisma') {
|
||||
return {
|
||||
variant,
|
||||
variantFamily: 'orm',
|
||||
tool: 'prisma',
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (variant === 'next-drizzle') {
|
||||
return {
|
||||
variant,
|
||||
variantFamily: 'orm',
|
||||
tool: 'drizzle-kit',
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (variant === 'react-router-supabase') {
|
||||
return {
|
||||
variant,
|
||||
variantFamily: 'supabase',
|
||||
tool: 'supabase',
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
variant: variant.includes('prisma') ? variant : 'next-supabase',
|
||||
variantFamily: 'supabase',
|
||||
tool: 'supabase',
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function readConfiguredVariant(rootPath: string) {
|
||||
const configPath = join(rootPath, '.makerkit', 'config.json');
|
||||
|
||||
try {
|
||||
await access(configPath);
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
return (
|
||||
readString(config, 'variant') ??
|
||||
readString(config, 'template') ??
|
||||
readString(config, 'kitVariant')
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
async function executeWithFallback(
|
||||
rootPath: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
) {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLocalCliCandidate(command)) {
|
||||
const localBinCandidates = [
|
||||
join(rootPath, 'node_modules', '.bin', command),
|
||||
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
|
||||
];
|
||||
|
||||
for (const localBin of localBinCandidates) {
|
||||
try {
|
||||
return await execFileAsync(localBin, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
// Try next local binary candidate.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await execFileAsync('pnpm', ['exec', command, ...args], {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
return execFileAsync(
|
||||
'pnpm',
|
||||
['--filter', 'web', 'exec', command, ...args],
|
||||
{
|
||||
cwd: rootPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === 'pnpm' || command === 'docker') {
|
||||
return execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalCliCandidate(command: string) {
|
||||
return (
|
||||
command === 'supabase' || command === 'drizzle-kit' || command === 'prisma'
|
||||
);
|
||||
}
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonIfPresent(path: string) {
|
||||
try {
|
||||
const content = await readFile(path, 'utf8');
|
||||
return JSON.parse(content) as {
|
||||
dependencies?: Record<string, unknown>;
|
||||
devDependencies?: Record<string, unknown>;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPort(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new Socket();
|
||||
|
||||
socket.setTimeout(200);
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
function buildErrorResponse(tool: string, error: unknown) {
|
||||
const message = `${tool} failed: ${toErrorMessage(error)}`;
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: {
|
||||
message,
|
||||
},
|
||||
},
|
||||
content: buildTextContent(message),
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
function buildTextContent(text: string): TextContent[] {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
export { createKitDbService } from './kit-db.service';
|
||||
export type { KitDbServiceDeps } from './kit-db.service';
|
||||
export type { KitDbStatusOutput } from './schema';
|
||||
505
packages/mcp-server/src/tools/db/kit-db.service.ts
Normal file
505
packages/mcp-server/src/tools/db/kit-db.service.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type {
|
||||
DbTool,
|
||||
KitDbMigrateInput,
|
||||
KitDbMigrateOutput,
|
||||
KitDbResetInput,
|
||||
KitDbResetOutput,
|
||||
KitDbSeedOutput,
|
||||
KitDbStatusOutput,
|
||||
} from './schema';
|
||||
|
||||
type VariantFamily = 'supabase' | 'orm';
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
interface VariantContext {
|
||||
variant: string;
|
||||
variantFamily: VariantFamily;
|
||||
tool: DbTool;
|
||||
}
|
||||
|
||||
interface MigrationStatus {
|
||||
applied: string[];
|
||||
pending: string[];
|
||||
}
|
||||
|
||||
interface SeedScript {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface KitDbServiceDeps {
|
||||
rootPath: string;
|
||||
resolveVariantContext(): Promise<VariantContext>;
|
||||
executeCommand(command: string, args: string[]): Promise<CommandResult>;
|
||||
isPortOpen(port: number): Promise<boolean>;
|
||||
fileExists(path: string): Promise<boolean>;
|
||||
readdir(path: string): Promise<string[]>;
|
||||
readJsonFile(path: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
const SUPABASE_PORT = 54321;
|
||||
const ORM_PORT = 5432;
|
||||
|
||||
export function createKitDbService(deps: KitDbServiceDeps) {
|
||||
return new KitDbService(deps);
|
||||
}
|
||||
|
||||
export class KitDbService {
|
||||
constructor(private readonly deps: KitDbServiceDeps) {}
|
||||
|
||||
async status(): Promise<KitDbStatusOutput> {
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
const connected = await this.isConnected(variant);
|
||||
const migrations = await this.getMigrationSummary(variant, {
|
||||
connected,
|
||||
});
|
||||
|
||||
return {
|
||||
connected,
|
||||
tool: variant.tool,
|
||||
migrations: {
|
||||
applied: migrations.applied.length,
|
||||
pending: migrations.pending.length,
|
||||
pending_names: migrations.pending,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async migrate(input: KitDbMigrateInput): Promise<KitDbMigrateOutput> {
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
|
||||
if (input.target !== 'latest') {
|
||||
throw new Error(
|
||||
`Specific migration targets are not supported for ${variant.tool} in this kit. Use target "latest".`,
|
||||
);
|
||||
}
|
||||
|
||||
const pending = await this.getPendingMigrationNames(variant);
|
||||
|
||||
await this.runMigrations(variant);
|
||||
|
||||
return {
|
||||
applied: pending,
|
||||
total_applied: pending.length,
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
async seed(): Promise<KitDbSeedOutput> {
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
const seedScript = await this.resolveSeedScript(variant);
|
||||
|
||||
await this.deps.executeCommand(seedScript.command, seedScript.args);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Seed data applied successfully',
|
||||
};
|
||||
}
|
||||
|
||||
async reset(input: KitDbResetInput): Promise<KitDbResetOutput> {
|
||||
if (!input.confirm) {
|
||||
throw new Error('Database reset requires confirm: true');
|
||||
}
|
||||
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
|
||||
if (variant.variantFamily === 'supabase') {
|
||||
await this.deps.executeCommand('supabase', ['db', 'reset']);
|
||||
} else {
|
||||
await this.deps.executeCommand('docker', ['compose', 'down', '-v']);
|
||||
await this.deps.executeCommand('docker', [
|
||||
'compose',
|
||||
'up',
|
||||
'-d',
|
||||
'postgres',
|
||||
]);
|
||||
await this.runMigrations(variant);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Database reset and migrations re-applied',
|
||||
};
|
||||
}
|
||||
|
||||
private async isConnected(variant: VariantContext) {
|
||||
const port =
|
||||
variant.variantFamily === 'supabase' ? SUPABASE_PORT : ORM_PORT;
|
||||
return this.deps.isPortOpen(port);
|
||||
}
|
||||
|
||||
private async getMigrationSummary(
|
||||
variant: VariantContext,
|
||||
options: {
|
||||
connected?: boolean;
|
||||
} = {},
|
||||
): Promise<MigrationStatus> {
|
||||
const localMigrations = await this.listLocalMigrations(variant);
|
||||
|
||||
if (variant.variantFamily === 'supabase') {
|
||||
const parsed = await this.tryParseSupabaseMigrations(localMigrations);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
variant.variantFamily === 'supabase' &&
|
||||
options.connected &&
|
||||
localMigrations.length > 0
|
||||
) {
|
||||
return {
|
||||
applied: localMigrations,
|
||||
pending: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
applied: [],
|
||||
pending: localMigrations,
|
||||
};
|
||||
}
|
||||
|
||||
private async getPendingMigrationNames(variant: VariantContext) {
|
||||
const summary = await this.getMigrationSummary(variant);
|
||||
return summary.pending;
|
||||
}
|
||||
|
||||
private async runMigrations(variant: VariantContext) {
|
||||
if (variant.tool === 'supabase') {
|
||||
await this.deps.executeCommand('supabase', ['db', 'push']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (variant.tool === 'drizzle-kit') {
|
||||
await this.deps.executeCommand('drizzle-kit', ['push']);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.executeCommand('prisma', ['db', 'push']);
|
||||
}
|
||||
|
||||
private async resolveSeedScript(
|
||||
variant: VariantContext,
|
||||
): Promise<SeedScript> {
|
||||
const customScript = await this.findSeedScript();
|
||||
|
||||
if (customScript) {
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: ['--filter', 'web', 'run', customScript],
|
||||
};
|
||||
}
|
||||
|
||||
if (variant.tool === 'supabase') {
|
||||
return {
|
||||
command: 'supabase',
|
||||
args: ['db', 'seed'],
|
||||
};
|
||||
}
|
||||
|
||||
if (variant.tool === 'prisma') {
|
||||
return {
|
||||
command: 'prisma',
|
||||
args: ['db', 'seed'],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No seed command configured. Add a db:seed or seed script to apps/web/package.json.',
|
||||
);
|
||||
}
|
||||
|
||||
private async findSeedScript() {
|
||||
const packageJsonPath = join(
|
||||
this.deps.rootPath,
|
||||
'apps',
|
||||
'web',
|
||||
'package.json',
|
||||
);
|
||||
|
||||
const packageJson = await this.readObject(packageJsonPath);
|
||||
const scripts = this.readObjectValue(packageJson, 'scripts');
|
||||
|
||||
if (this.readString(scripts, 'db:seed')) {
|
||||
return 'db:seed';
|
||||
}
|
||||
|
||||
if (this.readString(scripts, 'seed')) {
|
||||
return 'seed';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async listLocalMigrations(variant: VariantContext) {
|
||||
const migrationsDir = await this.resolveMigrationsDir(variant);
|
||||
|
||||
if (!migrationsDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await this.deps.readdir(migrationsDir);
|
||||
return this.filterMigrationNames(variant, entries);
|
||||
}
|
||||
|
||||
private async resolveMigrationsDir(variant: VariantContext) {
|
||||
if (variant.tool === 'supabase') {
|
||||
const supabaseDir = join(
|
||||
this.deps.rootPath,
|
||||
'apps',
|
||||
'web',
|
||||
'supabase',
|
||||
'migrations',
|
||||
);
|
||||
return (await this.deps.fileExists(supabaseDir)) ? supabaseDir : null;
|
||||
}
|
||||
|
||||
if (variant.tool === 'prisma') {
|
||||
const prismaDir = join(
|
||||
this.deps.rootPath,
|
||||
'apps',
|
||||
'web',
|
||||
'prisma',
|
||||
'migrations',
|
||||
);
|
||||
return (await this.deps.fileExists(prismaDir)) ? prismaDir : null;
|
||||
}
|
||||
|
||||
const drizzleDir = join(
|
||||
this.deps.rootPath,
|
||||
'apps',
|
||||
'web',
|
||||
'drizzle',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
if (await this.deps.fileExists(drizzleDir)) {
|
||||
return drizzleDir;
|
||||
}
|
||||
|
||||
const fallbackDir = join(this.deps.rootPath, 'drizzle', 'migrations');
|
||||
return (await this.deps.fileExists(fallbackDir)) ? fallbackDir : null;
|
||||
}
|
||||
|
||||
private filterMigrationNames(variant: VariantContext, entries: string[]) {
|
||||
if (variant.tool === 'prisma') {
|
||||
return entries.filter((entry) => entry.trim().length > 0);
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.endsWith('.sql'))
|
||||
.map((entry) => entry.replace(/\.sql$/, ''));
|
||||
}
|
||||
|
||||
private async tryParseSupabaseMigrations(localMigrations: string[]) {
|
||||
try {
|
||||
const localResult = await this.deps.executeCommand('supabase', [
|
||||
'migrations',
|
||||
'list',
|
||||
'--local',
|
||||
]);
|
||||
const parsedLocal = parseSupabaseMigrationsList(
|
||||
localResult.stdout,
|
||||
localMigrations,
|
||||
);
|
||||
if (parsedLocal) {
|
||||
return parsedLocal;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to remote attempt.
|
||||
}
|
||||
|
||||
try {
|
||||
const remoteResult = await this.deps.executeCommand('supabase', [
|
||||
'migrations',
|
||||
'list',
|
||||
]);
|
||||
return parseSupabaseMigrationsList(remoteResult.stdout, localMigrations);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readObject(path: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const value = await this.deps.readJsonFile(path);
|
||||
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private readObjectValue(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private readString(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSupabaseMigrationsList(
|
||||
output: string,
|
||||
localMigrations: string[],
|
||||
): MigrationStatus | null {
|
||||
const applied = new Set<string>();
|
||||
const pending = new Set<string>();
|
||||
const appliedCandidates = new Set<string>();
|
||||
const lines = output.split('\n');
|
||||
const migrationsById = buildMigrationIdMap(localMigrations);
|
||||
let sawStatus = false;
|
||||
let sawId = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = extractSupabaseStatus(trimmed);
|
||||
const nameFromLine = extractMigrationName(
|
||||
trimmed,
|
||||
localMigrations,
|
||||
migrationsById,
|
||||
);
|
||||
|
||||
if (nameFromLine) {
|
||||
sawId = true;
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
if (nameFromLine) {
|
||||
appliedCandidates.add(nameFromLine);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sawStatus = true;
|
||||
|
||||
if (!nameFromLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === 'applied') {
|
||||
applied.add(nameFromLine);
|
||||
} else {
|
||||
pending.add(nameFromLine);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawStatus && sawId && appliedCandidates.size > 0) {
|
||||
const appliedList = Array.from(appliedCandidates);
|
||||
const pendingList = localMigrations.filter(
|
||||
(migration) => !appliedCandidates.has(migration),
|
||||
);
|
||||
|
||||
return {
|
||||
applied: appliedList,
|
||||
pending: pendingList,
|
||||
};
|
||||
}
|
||||
|
||||
if (applied.size === 0 && pending.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
applied: Array.from(applied),
|
||||
pending: Array.from(pending),
|
||||
};
|
||||
}
|
||||
|
||||
function extractMigrationName(
|
||||
line: string,
|
||||
candidates: string[],
|
||||
migrationsById: Map<string, string>,
|
||||
) {
|
||||
const directMatch = line.match(/\b\d{14}_[a-z0-9_]+\b/i);
|
||||
if (directMatch?.[0]) {
|
||||
return directMatch[0];
|
||||
}
|
||||
|
||||
const columns = line
|
||||
.split('|')
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
if (columns.length >= 2) {
|
||||
const id = columns.find((value) => /^\d{14}$/.test(value));
|
||||
if (id) {
|
||||
const byId = migrationsById.get(id);
|
||||
if (byId) {
|
||||
return byId;
|
||||
}
|
||||
|
||||
const nameColumn = columns[1];
|
||||
const normalizedName = normalizeMigrationName(nameColumn);
|
||||
const candidate = `${id}_${normalizedName}`;
|
||||
const exactMatch = candidates.find(
|
||||
(migration) =>
|
||||
migration.toLowerCase() === candidate.toLowerCase() ||
|
||||
normalizeMigrationName(migration) === normalizedName,
|
||||
);
|
||||
|
||||
return exactMatch ?? candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.find((name) => line.includes(name)) ?? null;
|
||||
}
|
||||
|
||||
function extractSupabaseStatus(line: string) {
|
||||
const lower = line.toLowerCase();
|
||||
|
||||
if (/\b(not applied|pending|missing)\b/.test(lower)) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
if (/\b(applied|completed)\b/.test(lower)) {
|
||||
return 'applied';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMigrationIdMap(migrations: string[]) {
|
||||
const map = new Map<string, string>();
|
||||
|
||||
for (const migration of migrations) {
|
||||
const match = migration.match(/^(\d{14})_(.+)$/);
|
||||
if (match?.[1]) {
|
||||
map.set(match[1], migration);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function normalizeMigrationName(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9_-]/g, '');
|
||||
}
|
||||
53
packages/mcp-server/src/tools/db/schema.ts
Normal file
53
packages/mcp-server/src/tools/db/schema.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const DbToolSchema = z.enum(['supabase', 'drizzle-kit', 'prisma']);
|
||||
|
||||
const MigrationStatusSchema = z.object({
|
||||
applied: z.number(),
|
||||
pending: z.number(),
|
||||
pending_names: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const KitDbStatusInputSchema = z.object({});
|
||||
|
||||
export const KitDbStatusOutputSchema = z.object({
|
||||
connected: z.boolean(),
|
||||
tool: DbToolSchema,
|
||||
migrations: MigrationStatusSchema,
|
||||
});
|
||||
|
||||
export const KitDbMigrateInputSchema = z.object({
|
||||
target: z.string().default('latest'),
|
||||
});
|
||||
|
||||
export const KitDbMigrateOutputSchema = z.object({
|
||||
applied: z.array(z.string()),
|
||||
total_applied: z.number(),
|
||||
status: z.literal('success'),
|
||||
});
|
||||
|
||||
export const KitDbSeedInputSchema = z.object({});
|
||||
|
||||
export const KitDbSeedOutputSchema = z.object({
|
||||
status: z.literal('success'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const KitDbResetInputSchema = z.object({
|
||||
confirm: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const KitDbResetOutputSchema = z.object({
|
||||
status: z.literal('success'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type DbTool = z.infer<typeof DbToolSchema>;
|
||||
export type KitDbStatusInput = z.infer<typeof KitDbStatusInputSchema>;
|
||||
export type KitDbStatusOutput = z.infer<typeof KitDbStatusOutputSchema>;
|
||||
export type KitDbMigrateInput = z.infer<typeof KitDbMigrateInputSchema>;
|
||||
export type KitDbMigrateOutput = z.infer<typeof KitDbMigrateOutputSchema>;
|
||||
export type KitDbSeedInput = z.infer<typeof KitDbSeedInputSchema>;
|
||||
export type KitDbSeedOutput = z.infer<typeof KitDbSeedOutputSchema>;
|
||||
export type KitDbResetInput = z.infer<typeof KitDbResetInputSchema>;
|
||||
export type KitDbResetOutput = z.infer<typeof KitDbResetOutputSchema>;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type DepsUpgradeAdvisorDeps,
|
||||
createDepsUpgradeAdvisorService,
|
||||
} from '../deps-upgrade-advisor.service';
|
||||
|
||||
function createDeps(
|
||||
output: unknown,
|
||||
overrides: Partial<DepsUpgradeAdvisorDeps> = {},
|
||||
): DepsUpgradeAdvisorDeps {
|
||||
return {
|
||||
async executeCommand() {
|
||||
return {
|
||||
stdout: JSON.stringify(output),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
nowIso() {
|
||||
return '2026-02-09T00:00:00.000Z';
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DepsUpgradeAdvisorService', () => {
|
||||
it('flags major updates as potentially breaking', async () => {
|
||||
const service = createDepsUpgradeAdvisorService(
|
||||
createDeps([
|
||||
{
|
||||
name: 'zod',
|
||||
current: '3.25.0',
|
||||
wanted: '3.26.0',
|
||||
latest: '4.0.0',
|
||||
workspace: 'root',
|
||||
dependencyType: 'dependencies',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.advise({});
|
||||
const zod = result.recommendations.find((item) => item.package === 'zod');
|
||||
|
||||
expect(zod?.update_type).toBe('major');
|
||||
expect(zod?.potentially_breaking).toBe(true);
|
||||
expect(zod?.risk).toBe('high');
|
||||
});
|
||||
|
||||
it('prefers wanted for major updates when includeMajor is false', async () => {
|
||||
const service = createDepsUpgradeAdvisorService(
|
||||
createDeps([
|
||||
{
|
||||
name: 'example-lib',
|
||||
current: '1.2.0',
|
||||
wanted: '1.9.0',
|
||||
latest: '2.1.0',
|
||||
workspace: 'root',
|
||||
dependencyType: 'dependencies',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.advise({});
|
||||
const item = result.recommendations[0];
|
||||
|
||||
expect(item?.recommended_target).toBe('1.9.0');
|
||||
});
|
||||
|
||||
it('filters out dev dependencies when requested', async () => {
|
||||
const service = createDepsUpgradeAdvisorService(
|
||||
createDeps([
|
||||
{
|
||||
name: 'vitest',
|
||||
current: '2.1.0',
|
||||
wanted: '2.1.8',
|
||||
latest: '2.1.8',
|
||||
workspace: 'root',
|
||||
dependencyType: 'devDependencies',
|
||||
},
|
||||
{
|
||||
name: 'zod',
|
||||
current: '3.25.0',
|
||||
wanted: '3.25.1',
|
||||
latest: '3.25.1',
|
||||
workspace: 'root',
|
||||
dependencyType: 'dependencies',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.advise({
|
||||
state: { includeDevDependencies: false },
|
||||
});
|
||||
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
expect(result.recommendations[0]?.package).toBe('zod');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerDepsUpgradeAdvisorToolWithDeps } from '../index';
|
||||
import { DepsUpgradeAdvisorOutputSchema } from '../schema';
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
handler: (input: unknown) => Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
describe('registerDepsUpgradeAdvisorTool', () => {
|
||||
it('registers deps_upgrade_advisor and returns typed structured output', async () => {
|
||||
const tools: RegisteredTool[] = [];
|
||||
|
||||
const server = {
|
||||
registerTool(
|
||||
name: string,
|
||||
_config: Record<string, unknown>,
|
||||
handler: (input: unknown) => Promise<Record<string, unknown>>,
|
||||
) {
|
||||
tools.push({ name, handler });
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
registerDepsUpgradeAdvisorToolWithDeps(server as never, {
|
||||
async executeCommand() {
|
||||
return {
|
||||
stdout: '[]',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
nowIso() {
|
||||
return '2026-02-09T00:00:00.000Z';
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]?.name).toBe('deps_upgrade_advisor');
|
||||
|
||||
const result = await tools[0]!.handler({});
|
||||
const parsed = DepsUpgradeAdvisorOutputSchema.parse(
|
||||
result.structuredContent,
|
||||
);
|
||||
|
||||
expect(parsed.generated_at).toBeTruthy();
|
||||
expect(Array.isArray(parsed.recommendations)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import type {
|
||||
DepsUpgradeAdvisorInput,
|
||||
DepsUpgradeAdvisorOutput,
|
||||
DepsUpgradeRecommendation,
|
||||
} from './schema';
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
interface OutdatedDependency {
|
||||
package: string;
|
||||
workspace: string;
|
||||
dependencyType: string;
|
||||
current: string;
|
||||
wanted: string;
|
||||
latest: string;
|
||||
}
|
||||
|
||||
export interface DepsUpgradeAdvisorDeps {
|
||||
executeCommand(command: string, args: string[]): Promise<CommandResult>;
|
||||
nowIso(): string;
|
||||
}
|
||||
|
||||
export function createDepsUpgradeAdvisorService(deps: DepsUpgradeAdvisorDeps) {
|
||||
return new DepsUpgradeAdvisorService(deps);
|
||||
}
|
||||
|
||||
export class DepsUpgradeAdvisorService {
|
||||
constructor(private readonly deps: DepsUpgradeAdvisorDeps) {}
|
||||
|
||||
async advise(
|
||||
input: DepsUpgradeAdvisorInput,
|
||||
): Promise<DepsUpgradeAdvisorOutput> {
|
||||
const includeMajor = input.state?.includeMajor ?? false;
|
||||
const maxPackages = input.state?.maxPackages ?? 50;
|
||||
const includeDevDependencies = input.state?.includeDevDependencies ?? true;
|
||||
const warnings: string[] = [];
|
||||
|
||||
const outdated = await this.getOutdatedDependencies(warnings);
|
||||
|
||||
const filtered = outdated.filter((item) => {
|
||||
if (includeDevDependencies) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !item.dependencyType.toLowerCase().includes('dev');
|
||||
});
|
||||
|
||||
const recommendations = filtered
|
||||
.map((item) => toRecommendation(item, includeMajor))
|
||||
.sort(sortRecommendations)
|
||||
.slice(0, maxPackages);
|
||||
|
||||
const major = recommendations.filter(
|
||||
(item) => item.update_type === 'major',
|
||||
);
|
||||
const safe = recommendations.filter((item) => item.update_type !== 'major');
|
||||
|
||||
if (!includeMajor && major.length > 0) {
|
||||
warnings.push(
|
||||
`${major.length} major upgrades were excluded from immediate recommendations. Re-run with includeMajor=true to include them.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
generated_at: this.deps.nowIso(),
|
||||
summary: {
|
||||
total_outdated: filtered.length,
|
||||
recommended_now: recommendations.filter((item) =>
|
||||
includeMajor ? true : item.update_type !== 'major',
|
||||
).length,
|
||||
major_available: filtered
|
||||
.map((item) => toRecommendation(item, true))
|
||||
.filter((item) => item.update_type === 'major').length,
|
||||
minor_or_patch_available: filtered
|
||||
.map((item) => toRecommendation(item, true))
|
||||
.filter(
|
||||
(item) =>
|
||||
item.update_type === 'minor' || item.update_type === 'patch',
|
||||
).length,
|
||||
},
|
||||
recommendations,
|
||||
grouped_commands: {
|
||||
safe_batch_command: buildBatchCommand(
|
||||
safe.map((item) => `${item.package}@${item.recommended_target}`),
|
||||
),
|
||||
major_batch_command: includeMajor
|
||||
? buildBatchCommand(
|
||||
major.map((item) => `${item.package}@${item.recommended_target}`),
|
||||
)
|
||||
: null,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async getOutdatedDependencies(warnings: string[]) {
|
||||
const attempts: string[][] = [
|
||||
['outdated', '--recursive', '--format', 'json'],
|
||||
['outdated', '--recursive', '--json'],
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const args of attempts) {
|
||||
const result = await this.deps.executeCommand('pnpm', args);
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
if (result.exitCode === 0) {
|
||||
return [] as OutdatedDependency[];
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`pnpm ${args.join(' ')} returned no JSON output (exit code ${result.exitCode}).`,
|
||||
);
|
||||
lastError = new Error(result.stderr || 'Missing command output');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeOutdatedJson(JSON.parse(result.stdout));
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Invalid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Unable to retrieve outdated dependencies');
|
||||
}
|
||||
}
|
||||
|
||||
function toRecommendation(
|
||||
dependency: OutdatedDependency,
|
||||
includeMajor: boolean,
|
||||
): DepsUpgradeRecommendation {
|
||||
const updateType = getUpdateType(dependency.current, dependency.latest);
|
||||
const risk =
|
||||
updateType === 'major' ? 'high' : updateType === 'minor' ? 'medium' : 'low';
|
||||
const target =
|
||||
updateType === 'major' && !includeMajor
|
||||
? dependency.wanted
|
||||
: dependency.latest;
|
||||
|
||||
return {
|
||||
package: dependency.package,
|
||||
workspace: dependency.workspace,
|
||||
dependency_type: dependency.dependencyType,
|
||||
current: dependency.current,
|
||||
wanted: dependency.wanted,
|
||||
latest: dependency.latest,
|
||||
update_type: updateType,
|
||||
risk,
|
||||
potentially_breaking: updateType === 'major',
|
||||
recommended_target: target,
|
||||
recommended_command: `pnpm up -r ${dependency.package}@${target}`,
|
||||
reason:
|
||||
updateType === 'major' && !includeMajor
|
||||
? 'Major version available and potentially breaking; recommended target is the highest non-major range match.'
|
||||
: `Recommended ${updateType} update based on current vs latest version.`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutdatedJson(value: unknown): OutdatedDependency[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeOutdatedItem).filter((item) => item !== null);
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const rows: OutdatedDependency[] = [];
|
||||
|
||||
for (const [workspace, data] of Object.entries(value)) {
|
||||
if (!isRecord(data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [name, info] of Object.entries(data)) {
|
||||
if (!isRecord(info)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = readString(info, 'current');
|
||||
const wanted = readString(info, 'wanted');
|
||||
const latest = readString(info, 'latest');
|
||||
|
||||
if (!current || !wanted || !latest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
package: name,
|
||||
workspace,
|
||||
dependencyType: readString(info, 'dependencyType') ?? 'unknown',
|
||||
current,
|
||||
wanted,
|
||||
latest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeOutdatedItem(value: unknown): OutdatedDependency | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name =
|
||||
readString(value, 'name') ??
|
||||
readString(value, 'package') ??
|
||||
readString(value, 'pkgName');
|
||||
const current = readString(value, 'current');
|
||||
const wanted = readString(value, 'wanted');
|
||||
const latest = readString(value, 'latest');
|
||||
|
||||
if (!name || !current || !wanted || !latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
package: name,
|
||||
workspace:
|
||||
readString(value, 'workspace') ??
|
||||
readString(value, 'dependent') ??
|
||||
readString(value, 'location') ??
|
||||
'root',
|
||||
dependencyType:
|
||||
readString(value, 'dependencyType') ??
|
||||
readString(value, 'packageType') ??
|
||||
'unknown',
|
||||
current,
|
||||
wanted,
|
||||
latest,
|
||||
};
|
||||
}
|
||||
|
||||
function getUpdateType(current: string, latest: string) {
|
||||
const currentVersion = parseSemver(current);
|
||||
const latestVersion = parseSemver(latest);
|
||||
|
||||
if (!currentVersion || !latestVersion) {
|
||||
return 'unknown' as const;
|
||||
}
|
||||
|
||||
if (latestVersion.major > currentVersion.major) {
|
||||
return 'major' as const;
|
||||
}
|
||||
|
||||
if (latestVersion.minor > currentVersion.minor) {
|
||||
return 'minor' as const;
|
||||
}
|
||||
|
||||
if (latestVersion.patch > currentVersion.patch) {
|
||||
return 'patch' as const;
|
||||
}
|
||||
|
||||
return 'unknown' as const;
|
||||
}
|
||||
|
||||
function parseSemver(input: string) {
|
||||
const match = input.match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBatchCommand(upgrades: string[]) {
|
||||
if (upgrades.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `pnpm up -r ${upgrades.join(' ')}`;
|
||||
}
|
||||
|
||||
function sortRecommendations(
|
||||
a: DepsUpgradeRecommendation,
|
||||
b: DepsUpgradeRecommendation,
|
||||
) {
|
||||
const rank: Record<DepsUpgradeRecommendation['risk'], number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
return rank[a.risk] - rank[b.risk] || a.package.localeCompare(b.package);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string) {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
122
packages/mcp-server/src/tools/deps-upgrade-advisor/index.ts
Normal file
122
packages/mcp-server/src/tools/deps-upgrade-advisor/index.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type DepsUpgradeAdvisorDeps,
|
||||
createDepsUpgradeAdvisorService,
|
||||
} from './deps-upgrade-advisor.service';
|
||||
import {
|
||||
DepsUpgradeAdvisorInputSchema,
|
||||
DepsUpgradeAdvisorOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function registerDepsUpgradeAdvisorTool(server: McpServer) {
|
||||
return registerDepsUpgradeAdvisorToolWithDeps(
|
||||
server,
|
||||
createDepsUpgradeAdvisorDeps(),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerDepsUpgradeAdvisorToolWithDeps(
|
||||
server: McpServer,
|
||||
deps: DepsUpgradeAdvisorDeps,
|
||||
) {
|
||||
const service = createDepsUpgradeAdvisorService(deps);
|
||||
|
||||
return server.registerTool(
|
||||
'deps_upgrade_advisor',
|
||||
{
|
||||
description:
|
||||
'Analyze outdated dependencies and return risk-bucketed upgrade recommendations',
|
||||
inputSchema: DepsUpgradeAdvisorInputSchema,
|
||||
outputSchema: DepsUpgradeAdvisorOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = DepsUpgradeAdvisorInputSchema.parse(input);
|
||||
const result = await service.advise(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `deps_upgrade_advisor failed: ${toErrorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createDepsUpgradeAdvisorDeps(): DepsUpgradeAdvisorDeps {
|
||||
const rootPath = process.cwd();
|
||||
|
||||
return {
|
||||
async executeCommand(command, args) {
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isExecError(error)) {
|
||||
return {
|
||||
stdout: error.stdout ?? '',
|
||||
stderr: error.stderr ?? '',
|
||||
exitCode: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
nowIso() {
|
||||
return new Date().toISOString();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecError extends Error {
|
||||
code: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
function isExecError(error: unknown): error is ExecError {
|
||||
return error instanceof Error && 'code' in error;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
export {
|
||||
createDepsUpgradeAdvisorService,
|
||||
type DepsUpgradeAdvisorDeps,
|
||||
} from './deps-upgrade-advisor.service';
|
||||
export type { DepsUpgradeAdvisorOutput } from './schema';
|
||||
52
packages/mcp-server/src/tools/deps-upgrade-advisor/schema.ts
Normal file
52
packages/mcp-server/src/tools/deps-upgrade-advisor/schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export const DepsUpgradeAdvisorInputSchema = z.object({
|
||||
state: z
|
||||
.object({
|
||||
includeMajor: z.boolean().optional(),
|
||||
maxPackages: z.number().int().min(1).max(200).optional(),
|
||||
includeDevDependencies: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DepsUpgradeRecommendationSchema = z.object({
|
||||
package: z.string(),
|
||||
workspace: z.string(),
|
||||
dependency_type: z.string(),
|
||||
current: z.string(),
|
||||
wanted: z.string(),
|
||||
latest: z.string(),
|
||||
update_type: z.enum(['major', 'minor', 'patch', 'unknown']),
|
||||
risk: z.enum(['high', 'medium', 'low']),
|
||||
potentially_breaking: z.boolean(),
|
||||
recommended_target: z.string(),
|
||||
recommended_command: z.string(),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export const DepsUpgradeAdvisorOutputSchema = z.object({
|
||||
generated_at: z.string(),
|
||||
summary: z.object({
|
||||
total_outdated: z.number().int().min(0),
|
||||
recommended_now: z.number().int().min(0),
|
||||
major_available: z.number().int().min(0),
|
||||
minor_or_patch_available: z.number().int().min(0),
|
||||
}),
|
||||
recommendations: z.array(DepsUpgradeRecommendationSchema),
|
||||
grouped_commands: z.object({
|
||||
safe_batch_command: z.string().nullable(),
|
||||
major_batch_command: z.string().nullable(),
|
||||
}),
|
||||
warnings: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type DepsUpgradeAdvisorInput = z.infer<
|
||||
typeof DepsUpgradeAdvisorInputSchema
|
||||
>;
|
||||
export type DepsUpgradeRecommendation = z.infer<
|
||||
typeof DepsUpgradeRecommendationSchema
|
||||
>;
|
||||
export type DepsUpgradeAdvisorOutput = z.infer<
|
||||
typeof DepsUpgradeAdvisorOutputSchema
|
||||
>;
|
||||
1016
packages/mcp-server/src/tools/dev/__tests__/kit-dev.service.test.ts
Normal file
1016
packages/mcp-server/src/tools/dev/__tests__/kit-dev.service.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
494
packages/mcp-server/src/tools/dev/index.ts
Normal file
494
packages/mcp-server/src/tools/dev/index.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { Socket } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
DEFAULT_PORT_CONFIG,
|
||||
type KitDevServiceDeps,
|
||||
createKitDevService,
|
||||
} from './kit-dev.service';
|
||||
import {
|
||||
KitDevStartInputSchema,
|
||||
KitDevStartOutputSchema,
|
||||
KitDevStatusInputSchema,
|
||||
KitDevStatusOutputSchema,
|
||||
KitDevStopInputSchema,
|
||||
KitDevStopOutputSchema,
|
||||
KitMailboxStatusInputSchema,
|
||||
KitMailboxStatusOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function registerKitDevTools(server: McpServer) {
|
||||
const service = createKitDevService(createKitDevDeps());
|
||||
|
||||
server.registerTool(
|
||||
'kit_dev_start',
|
||||
{
|
||||
description: 'Start all or selected local development services',
|
||||
inputSchema: KitDevStartInputSchema,
|
||||
outputSchema: KitDevStartOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
const parsedInput = KitDevStartInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const result = await service.start(parsedInput);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `kit_dev_start failed: ${toErrorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_dev_stop',
|
||||
{
|
||||
description: 'Stop all or selected local development services',
|
||||
inputSchema: KitDevStopInputSchema,
|
||||
outputSchema: KitDevStopOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
const parsedInput = KitDevStopInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const result = await service.stop(parsedInput);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `kit_dev_stop failed: ${toErrorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_dev_status',
|
||||
{
|
||||
description:
|
||||
'Check status for app, database, mailbox, and stripe local services',
|
||||
inputSchema: KitDevStatusInputSchema,
|
||||
outputSchema: KitDevStatusOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
KitDevStatusInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const result = await service.status();
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `kit_dev_status failed: ${toErrorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_mailbox_status',
|
||||
{
|
||||
description:
|
||||
'Check local mailbox health with graceful fallback fields for UI state',
|
||||
inputSchema: KitMailboxStatusInputSchema,
|
||||
outputSchema: KitMailboxStatusOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
KitMailboxStatusInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const result = await service.mailboxStatus();
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `kit_mailbox_status failed: ${toErrorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createKitDevDeps(rootPath = process.cwd()): KitDevServiceDeps {
|
||||
return {
|
||||
rootPath,
|
||||
async resolveVariantContext() {
|
||||
const packageJson = await readJsonIfPresent(
|
||||
join(rootPath, 'apps', 'web', 'package.json'),
|
||||
);
|
||||
const hasSupabase = await pathExists(
|
||||
join(rootPath, 'apps', 'web', 'supabase'),
|
||||
);
|
||||
|
||||
const dependencies = {
|
||||
...(packageJson?.dependencies ?? {}),
|
||||
...(packageJson?.devDependencies ?? {}),
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const framework =
|
||||
'react-router' in dependencies || '@react-router/dev' in dependencies
|
||||
? 'react-router'
|
||||
: 'nextjs';
|
||||
|
||||
if (hasSupabase) {
|
||||
return {
|
||||
variant:
|
||||
framework === 'react-router'
|
||||
? 'react-router-supabase'
|
||||
: 'next-supabase',
|
||||
variantFamily: 'supabase',
|
||||
framework,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
variant:
|
||||
framework === 'react-router'
|
||||
? 'react-router-drizzle'
|
||||
: 'next-drizzle',
|
||||
variantFamily: 'orm',
|
||||
framework,
|
||||
} as const;
|
||||
},
|
||||
async resolvePortConfig() {
|
||||
const configTomlPath = join(
|
||||
rootPath,
|
||||
'apps',
|
||||
'web',
|
||||
'supabase',
|
||||
'config.toml',
|
||||
);
|
||||
|
||||
let supabaseApiPort = DEFAULT_PORT_CONFIG.supabaseApiPort;
|
||||
let supabaseStudioPort = DEFAULT_PORT_CONFIG.supabaseStudioPort;
|
||||
|
||||
try {
|
||||
const toml = await readFile(configTomlPath, 'utf8');
|
||||
supabaseApiPort = parseTomlSectionPort(toml, 'api') ?? supabaseApiPort;
|
||||
supabaseStudioPort =
|
||||
parseTomlSectionPort(toml, 'studio') ?? supabaseStudioPort;
|
||||
} catch {
|
||||
// config.toml not present or unreadable — use defaults.
|
||||
}
|
||||
|
||||
return {
|
||||
appPort: DEFAULT_PORT_CONFIG.appPort,
|
||||
supabaseApiPort,
|
||||
supabaseStudioPort,
|
||||
mailboxApiPort: DEFAULT_PORT_CONFIG.mailboxApiPort,
|
||||
mailboxPort: DEFAULT_PORT_CONFIG.mailboxPort,
|
||||
ormDbPort: DEFAULT_PORT_CONFIG.ormDbPort,
|
||||
stripeWebhookPath: DEFAULT_PORT_CONFIG.stripeWebhookPath,
|
||||
};
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await executeWithFallback(rootPath, command, args);
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
async spawnDetached(command: string, args: string[]) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootPath,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error(`Failed to spawn ${command}`);
|
||||
}
|
||||
|
||||
return {
|
||||
pid: child.pid,
|
||||
};
|
||||
},
|
||||
async isPortOpen(port: number) {
|
||||
return checkPort(port);
|
||||
},
|
||||
async fetchJson(url: string) {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
async getPortProcess(port: number) {
|
||||
try {
|
||||
const result = await execFileAsync(
|
||||
'lsof',
|
||||
['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'],
|
||||
{
|
||||
cwd: rootPath,
|
||||
},
|
||||
);
|
||||
|
||||
const pidLine = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('p'));
|
||||
|
||||
const commandLine = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('c'));
|
||||
|
||||
if (!pidLine || !commandLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number(pidLine.slice(1));
|
||||
|
||||
if (!Number.isFinite(pid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pid,
|
||||
command: commandLine.slice(1),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async isProcessRunning(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async findProcessesByName(pattern: string) {
|
||||
try {
|
||||
const result = await execFileAsync('pgrep', ['-fl', pattern], {
|
||||
cwd: rootPath,
|
||||
});
|
||||
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const spaceIdx = line.indexOf(' ');
|
||||
|
||||
if (spaceIdx <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number(line.slice(0, spaceIdx));
|
||||
const command = line.slice(spaceIdx + 1).trim();
|
||||
|
||||
if (!Number.isFinite(pid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { pid, command };
|
||||
})
|
||||
.filter((p): p is { pid: number; command: string } => p !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
async killProcess(pid: number, signal = 'SIGTERM') {
|
||||
try {
|
||||
// Kill the entire process group (negative PID) since services
|
||||
// are spawned detached and become process group leaders.
|
||||
process.kill(-pid, signal);
|
||||
} catch {
|
||||
// Fall back to killing the individual process if group kill fails.
|
||||
process.kill(pid, signal);
|
||||
}
|
||||
},
|
||||
async sleep(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeWithFallback(
|
||||
rootPath: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
) {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLocalCliCandidate(command)) {
|
||||
const localBinCandidates = [
|
||||
join(rootPath, 'node_modules', '.bin', command),
|
||||
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
|
||||
];
|
||||
|
||||
for (const localBin of localBinCandidates) {
|
||||
try {
|
||||
return await execFileAsync(localBin, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
// Try next local binary candidate.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await execFileAsync('pnpm', ['exec', command, ...args], {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
return execFileAsync(
|
||||
'pnpm',
|
||||
['--filter', 'web', 'exec', command, ...args],
|
||||
{
|
||||
cwd: rootPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalCliCandidate(command: string) {
|
||||
return command === 'supabase' || command === 'stripe';
|
||||
}
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonIfPresent(path: string) {
|
||||
try {
|
||||
const content = await readFile(path, 'utf8');
|
||||
return JSON.parse(content) as {
|
||||
dependencies?: Record<string, unknown>;
|
||||
devDependencies?: Record<string, unknown>;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPort(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new Socket();
|
||||
|
||||
socket.setTimeout(200);
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
function parseTomlSectionPort(
|
||||
content: string,
|
||||
section: string,
|
||||
): number | undefined {
|
||||
const lines = content.split('\n');
|
||||
let inSection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
inSection = trimmed === `[${section}]`;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSection) {
|
||||
const match = trimmed.match(/^port\s*=\s*(\d+)/);
|
||||
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
export { createKitDevService, DEFAULT_PORT_CONFIG } from './kit-dev.service';
|
||||
export type { KitDevServiceDeps, PortConfig } from './kit-dev.service';
|
||||
export type {
|
||||
KitDevStartOutput,
|
||||
KitDevStatusOutput,
|
||||
KitDevStopOutput,
|
||||
} from './schema';
|
||||
723
packages/mcp-server/src/tools/dev/kit-dev.service.ts
Normal file
723
packages/mcp-server/src/tools/dev/kit-dev.service.ts
Normal file
@@ -0,0 +1,723 @@
|
||||
import type {
|
||||
DevServiceId,
|
||||
DevServiceSelection,
|
||||
DevServiceStatusItem,
|
||||
KitDevStartInput,
|
||||
KitDevStartOutput,
|
||||
KitDevStatusOutput,
|
||||
KitDevStopInput,
|
||||
KitDevStopOutput,
|
||||
KitMailboxStatusOutput,
|
||||
} from './schema';
|
||||
|
||||
type VariantFamily = 'supabase' | 'orm';
|
||||
type Framework = 'nextjs' | 'react-router';
|
||||
|
||||
type SignalName = 'SIGTERM' | 'SIGKILL';
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface SpawnedProcess {
|
||||
pid: number;
|
||||
}
|
||||
|
||||
interface VariantContext {
|
||||
variant: string;
|
||||
variantFamily: VariantFamily;
|
||||
framework: Framework;
|
||||
}
|
||||
|
||||
export interface PortConfig {
|
||||
appPort: number;
|
||||
supabaseApiPort: number;
|
||||
supabaseStudioPort: number;
|
||||
mailboxApiPort: number;
|
||||
mailboxPort: number;
|
||||
ormDbPort: number;
|
||||
stripeWebhookPath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PORT_CONFIG: PortConfig = {
|
||||
appPort: 3000,
|
||||
supabaseApiPort: 54321,
|
||||
supabaseStudioPort: 54323,
|
||||
mailboxApiPort: 54324,
|
||||
mailboxPort: 8025,
|
||||
ormDbPort: 5432,
|
||||
stripeWebhookPath: '/api/billing/webhook',
|
||||
};
|
||||
|
||||
export interface KitDevServiceDeps {
|
||||
rootPath: string;
|
||||
resolveVariantContext(): Promise<VariantContext>;
|
||||
resolvePortConfig(): Promise<PortConfig>;
|
||||
executeCommand(command: string, args: string[]): Promise<CommandResult>;
|
||||
spawnDetached(command: string, args: string[]): Promise<SpawnedProcess>;
|
||||
isPortOpen(port: number): Promise<boolean>;
|
||||
getPortProcess(port: number): Promise<ProcessInfo | null>;
|
||||
isProcessRunning(pid: number): Promise<boolean>;
|
||||
findProcessesByName(pattern: string): Promise<ProcessInfo[]>;
|
||||
killProcess(pid: number, signal?: SignalName): Promise<void>;
|
||||
sleep(ms: number): Promise<void>;
|
||||
fetchJson(url: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface MailboxHealth {
|
||||
connected: boolean;
|
||||
running: boolean;
|
||||
apiReachable: boolean;
|
||||
url: string;
|
||||
port: number;
|
||||
reason?: string;
|
||||
diagnostics?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function createKitDevService(deps: KitDevServiceDeps) {
|
||||
return new KitDevService(deps);
|
||||
}
|
||||
|
||||
export class KitDevService {
|
||||
constructor(private readonly deps: KitDevServiceDeps) {}
|
||||
|
||||
async start(input: KitDevStartInput): Promise<KitDevStartOutput> {
|
||||
const selectedServices = this.expandServices(input.services);
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
const ports = await this.deps.resolvePortConfig();
|
||||
|
||||
const startedPids: Partial<Record<DevServiceId, number>> = {};
|
||||
|
||||
if (selectedServices.includes('database')) {
|
||||
const running = await this.isDatabaseRunning(variant, ports);
|
||||
|
||||
if (!running) {
|
||||
await this.startDatabase(variant);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
selectedServices.includes('mailbox') &&
|
||||
variant.variantFamily === 'supabase'
|
||||
) {
|
||||
const mailbox = await this.collectMailboxHealth(variant, ports);
|
||||
|
||||
if (!mailbox.connected) {
|
||||
await this.startDatabase(variant);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedServices.includes('app')) {
|
||||
const running = await this.deps.isPortOpen(ports.appPort);
|
||||
|
||||
if (!running) {
|
||||
startedPids.app = await this.startApp(variant, ports);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedServices.includes('stripe')) {
|
||||
const procs = await this.deps.findProcessesByName('stripe.*listen');
|
||||
|
||||
if (procs.length === 0) {
|
||||
startedPids.stripe = await this.startStripe(ports);
|
||||
}
|
||||
}
|
||||
|
||||
const status = await this.collectStatus(variant, ports, startedPids);
|
||||
|
||||
return {
|
||||
services: status.filter((service) =>
|
||||
selectedServices.includes(service.id),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async stop(input: KitDevStopInput): Promise<KitDevStopOutput> {
|
||||
const selectedServices = this.expandServices(input.services);
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
const ports = await this.deps.resolvePortConfig();
|
||||
|
||||
const stopped = new Set<DevServiceId>();
|
||||
|
||||
if (selectedServices.includes('stripe')) {
|
||||
const procs = await this.deps.findProcessesByName('stripe.*listen');
|
||||
|
||||
for (const proc of procs) {
|
||||
await this.stopProcess(proc.pid);
|
||||
}
|
||||
|
||||
stopped.add('stripe');
|
||||
}
|
||||
|
||||
if (selectedServices.includes('app')) {
|
||||
const proc = await this.deps.getPortProcess(ports.appPort);
|
||||
|
||||
if (proc) {
|
||||
await this.stopProcess(proc.pid);
|
||||
}
|
||||
|
||||
stopped.add('app');
|
||||
}
|
||||
|
||||
const shouldStopDatabase =
|
||||
selectedServices.includes('database') ||
|
||||
(selectedServices.includes('mailbox') &&
|
||||
variant.variantFamily === 'supabase');
|
||||
|
||||
if (shouldStopDatabase) {
|
||||
try {
|
||||
await this.stopDatabase(variant);
|
||||
} catch {
|
||||
// Best-effort — the database process may already be stopped or
|
||||
// the CLI may not be available.
|
||||
}
|
||||
|
||||
if (selectedServices.includes('database')) {
|
||||
stopped.add('database');
|
||||
}
|
||||
if (selectedServices.includes('mailbox')) {
|
||||
stopped.add('mailbox');
|
||||
}
|
||||
} else if (selectedServices.includes('mailbox')) {
|
||||
stopped.add('mailbox');
|
||||
}
|
||||
|
||||
return {
|
||||
stopped: Array.from(stopped),
|
||||
};
|
||||
}
|
||||
|
||||
async status(): Promise<KitDevStatusOutput> {
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
const ports = await this.deps.resolvePortConfig();
|
||||
|
||||
const services = await this.collectStatus(variant, ports);
|
||||
|
||||
return {
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
async mailboxStatus(): Promise<KitMailboxStatusOutput> {
|
||||
const variant = await this.deps.resolveVariantContext();
|
||||
const ports = await this.deps.resolvePortConfig();
|
||||
const mailbox = await this.collectMailboxHealth(variant, ports);
|
||||
|
||||
return {
|
||||
connected: mailbox.connected,
|
||||
running: mailbox.running,
|
||||
api_reachable: mailbox.apiReachable,
|
||||
url: mailbox.url,
|
||||
port: mailbox.port,
|
||||
reason: mailbox.reason,
|
||||
};
|
||||
}
|
||||
|
||||
private expandServices(services: DevServiceSelection[]): DevServiceId[] {
|
||||
if (services.includes('all')) {
|
||||
return ['app', 'database', 'mailbox', 'stripe'];
|
||||
}
|
||||
|
||||
const normalized = services.map((service) =>
|
||||
service === 'mailpit' ? 'mailbox' : service,
|
||||
);
|
||||
|
||||
return Array.from(new Set(normalized)) as DevServiceId[];
|
||||
}
|
||||
|
||||
private async isDatabaseRunning(
|
||||
variant: VariantContext,
|
||||
ports: PortConfig,
|
||||
): Promise<boolean> {
|
||||
if (variant.variantFamily === 'supabase') {
|
||||
const apiOpen = await this.deps.isPortOpen(ports.supabaseApiPort);
|
||||
const studioOpen = await this.deps.isPortOpen(ports.supabaseStudioPort);
|
||||
|
||||
return apiOpen || studioOpen;
|
||||
}
|
||||
|
||||
return this.deps.isPortOpen(ports.ormDbPort);
|
||||
}
|
||||
|
||||
private async startDatabase(variant: VariantContext) {
|
||||
if (variant.variantFamily === 'supabase') {
|
||||
await this.deps.executeCommand('pnpm', [
|
||||
'--filter',
|
||||
'web',
|
||||
'supabase:start',
|
||||
]);
|
||||
await this.deps.sleep(400);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.executeCommand('docker', [
|
||||
'compose',
|
||||
'up',
|
||||
'-d',
|
||||
'postgres',
|
||||
]);
|
||||
}
|
||||
|
||||
private async stopDatabase(variant: VariantContext) {
|
||||
if (variant.variantFamily === 'supabase') {
|
||||
await this.deps.executeCommand('pnpm', [
|
||||
'--filter',
|
||||
'web',
|
||||
'supabase:stop',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.executeCommand('docker', ['compose', 'stop', 'postgres']);
|
||||
}
|
||||
|
||||
private async startApp(
|
||||
variant: VariantContext,
|
||||
ports: PortConfig,
|
||||
): Promise<number> {
|
||||
const args =
|
||||
variant.framework === 'react-router'
|
||||
? ['exec', 'react-router', 'dev', '--port', String(ports.appPort)]
|
||||
: [
|
||||
'--filter',
|
||||
'web',
|
||||
'exec',
|
||||
'next',
|
||||
'dev',
|
||||
'--port',
|
||||
String(ports.appPort),
|
||||
];
|
||||
|
||||
const process = await this.deps.spawnDetached('pnpm', args);
|
||||
|
||||
await this.deps.sleep(500);
|
||||
|
||||
return process.pid;
|
||||
}
|
||||
|
||||
private async startStripe(ports: PortConfig): Promise<number> {
|
||||
const webhookUrl = `http://localhost:${ports.appPort}${ports.stripeWebhookPath}`;
|
||||
const process = await this.deps.spawnDetached('pnpm', [
|
||||
'exec',
|
||||
'stripe',
|
||||
'listen',
|
||||
'--forward-to',
|
||||
webhookUrl,
|
||||
]);
|
||||
|
||||
return process.pid;
|
||||
}
|
||||
|
||||
private async collectStatus(
|
||||
variant: VariantContext,
|
||||
ports: PortConfig,
|
||||
startedPids: Partial<Record<DevServiceId, number>> = {},
|
||||
): Promise<DevServiceStatusItem[]> {
|
||||
const app = await this.collectAppStatus(variant, ports, startedPids);
|
||||
const database = await this.collectDatabaseStatus(variant, ports);
|
||||
const mailbox = await this.collectMailboxStatus(variant, ports);
|
||||
const stripe = await this.collectStripeStatus(ports, startedPids);
|
||||
|
||||
return [app, database, mailbox, stripe];
|
||||
}
|
||||
|
||||
private async collectAppStatus(
|
||||
variant: VariantContext,
|
||||
ports: PortConfig,
|
||||
startedPids: Partial<Record<DevServiceId, number>> = {},
|
||||
): Promise<DevServiceStatusItem> {
|
||||
const name =
|
||||
variant.framework === 'react-router'
|
||||
? 'React Router Dev Server'
|
||||
: 'Next.js Dev Server';
|
||||
|
||||
const portOpen = await this.deps.isPortOpen(ports.appPort);
|
||||
const proc = portOpen
|
||||
? await this.deps.getPortProcess(ports.appPort)
|
||||
: null;
|
||||
|
||||
// If we just started the app, the port may not be open yet.
|
||||
// Fall back to checking if the spawned process is alive.
|
||||
const justStartedPid = startedPids.app;
|
||||
const justStartedAlive = justStartedPid
|
||||
? await this.deps.isProcessRunning(justStartedPid)
|
||||
: false;
|
||||
|
||||
const running = portOpen || justStartedAlive;
|
||||
|
||||
return {
|
||||
id: 'app',
|
||||
name,
|
||||
status: running ? 'running' : 'stopped',
|
||||
port: ports.appPort,
|
||||
url: running ? `http://localhost:${ports.appPort}` : undefined,
|
||||
pid: proc?.pid ?? (justStartedAlive ? justStartedPid : null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async collectDatabaseStatus(
|
||||
variant: VariantContext,
|
||||
ports: PortConfig,
|
||||
): Promise<DevServiceStatusItem> {
|
||||
if (variant.variantFamily === 'supabase') {
|
||||
const extras = await this.resolveSupabaseExtras();
|
||||
const apiPort =
|
||||
extractPortFromUrl(extras.api_url) ?? ports.supabaseApiPort;
|
||||
const studioPort =
|
||||
extractPortFromUrl(extras.studio_url) ?? ports.supabaseStudioPort;
|
||||
const portOpen = await this.deps.isPortOpen(apiPort);
|
||||
const studioOpen = await this.deps.isPortOpen(studioPort);
|
||||
const running = portOpen || studioOpen;
|
||||
|
||||
return {
|
||||
id: 'database',
|
||||
name: 'Supabase',
|
||||
status: running ? 'running' : 'stopped',
|
||||
port: apiPort,
|
||||
url:
|
||||
extras.api_url ??
|
||||
(running ? `http://127.0.0.1:${apiPort}` : undefined),
|
||||
extras: running
|
||||
? {
|
||||
...(extras.studio_url ? { studio_url: extras.studio_url } : {}),
|
||||
...(extras.anon_key ? { anon_key: extras.anon_key } : {}),
|
||||
...(extras.service_role_key
|
||||
? { service_role_key: extras.service_role_key }
|
||||
: {}),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const running = await this.deps.isPortOpen(ports.ormDbPort);
|
||||
|
||||
return {
|
||||
id: 'database',
|
||||
name: 'PostgreSQL',
|
||||
status: running ? 'running' : 'stopped',
|
||||
port: ports.ormDbPort,
|
||||
url: running ? `postgresql://localhost:${ports.ormDbPort}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async collectStripeStatus(
|
||||
ports: PortConfig,
|
||||
startedPids: Partial<Record<DevServiceId, number>> = {},
|
||||
): Promise<DevServiceStatusItem> {
|
||||
const procs = await this.deps.findProcessesByName('stripe.*listen');
|
||||
const justStartedPid = startedPids.stripe;
|
||||
const justStartedAlive = justStartedPid
|
||||
? await this.deps.isProcessRunning(justStartedPid)
|
||||
: false;
|
||||
|
||||
const running = procs.length > 0 || justStartedAlive;
|
||||
const pid =
|
||||
procs[0]?.pid ?? (justStartedAlive ? justStartedPid : undefined);
|
||||
|
||||
const webhookUrl = procs[0]?.command
|
||||
? (extractForwardToUrl(procs[0].command) ??
|
||||
`http://localhost:${ports.appPort}${ports.stripeWebhookPath}`)
|
||||
: `http://localhost:${ports.appPort}${ports.stripeWebhookPath}`;
|
||||
|
||||
return {
|
||||
id: 'stripe',
|
||||
name: 'Stripe CLI',
|
||||
status: running ? 'running' : 'stopped',
|
||||
pid: pid ?? null,
|
||||
webhook_url: running ? webhookUrl : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async collectMailboxStatus(
|
||||
variant: VariantContext,
|
||||
ports: PortConfig,
|
||||
): Promise<DevServiceStatusItem> {
|
||||
const mailbox = await this.collectMailboxHealth(variant, ports);
|
||||
|
||||
return {
|
||||
id: 'mailbox',
|
||||
name: 'Mailbox',
|
||||
status: mailbox.running
|
||||
? 'running'
|
||||
: mailbox.connected && !mailbox.apiReachable
|
||||
? 'error'
|
||||
: 'stopped',
|
||||
port: mailbox.port,
|
||||
url: mailbox.url,
|
||||
extras: mailbox.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
private async collectMailboxHealth(
|
||||
variant: VariantContext,
|
||||
ports: PortConfig,
|
||||
): Promise<MailboxHealth> {
|
||||
const mailboxUrl = `http://localhost:${ports.mailboxPort}`;
|
||||
const mailboxApiUrl = `http://127.0.0.1:${ports.mailboxApiPort}/api/v1/info`;
|
||||
|
||||
if (variant.variantFamily !== 'supabase') {
|
||||
return {
|
||||
connected: false,
|
||||
running: false,
|
||||
apiReachable: false,
|
||||
url: mailboxUrl,
|
||||
port: ports.mailboxPort,
|
||||
reason: 'Mailbox is only available for Supabase variants',
|
||||
};
|
||||
}
|
||||
|
||||
const [apiReachable, containerStatus] = await Promise.all([
|
||||
this.checkMailboxApi(mailboxApiUrl),
|
||||
this.resolveMailboxContainerStatus(),
|
||||
]);
|
||||
|
||||
if (apiReachable.ok) {
|
||||
return {
|
||||
connected: true,
|
||||
running: true,
|
||||
apiReachable: true,
|
||||
url: mailboxUrl,
|
||||
port: ports.mailboxPort,
|
||||
};
|
||||
}
|
||||
|
||||
if (containerStatus.running) {
|
||||
const reason =
|
||||
'Mailbox container is running, but Mailpit API is unreachable';
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
running: false,
|
||||
apiReachable: false,
|
||||
url: mailboxUrl,
|
||||
port: ports.mailboxPort,
|
||||
reason,
|
||||
diagnostics: {
|
||||
reason,
|
||||
api_url: mailboxApiUrl,
|
||||
...(apiReachable.error ? { api_error: apiReachable.error } : {}),
|
||||
...(containerStatus.source
|
||||
? { container_source: containerStatus.source }
|
||||
: {}),
|
||||
...(containerStatus.details
|
||||
? { container_details: containerStatus.details }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
connected: false,
|
||||
running: false,
|
||||
apiReachable: false,
|
||||
url: mailboxUrl,
|
||||
port: ports.mailboxPort,
|
||||
reason: 'Mailbox is not running',
|
||||
diagnostics: {
|
||||
reason: 'Mailbox is not running',
|
||||
api_url: mailboxApiUrl,
|
||||
...(apiReachable.error ? { api_error: apiReachable.error } : {}),
|
||||
...(containerStatus.details
|
||||
? { container_details: containerStatus.details }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveMailboxContainerStatus(): Promise<{
|
||||
running: boolean;
|
||||
source?: string;
|
||||
details?: string;
|
||||
}> {
|
||||
const dockerComposeResult = await this.tryGetRunningServicesFromCommand(
|
||||
'docker',
|
||||
[
|
||||
'compose',
|
||||
'-f',
|
||||
'docker-compose.dev.yml',
|
||||
'ps',
|
||||
'--status',
|
||||
'running',
|
||||
'--services',
|
||||
],
|
||||
'docker-compose.dev.yml',
|
||||
);
|
||||
|
||||
if (dockerComposeResult.running) {
|
||||
return dockerComposeResult;
|
||||
}
|
||||
|
||||
const supabaseDockerResult = await this.tryGetRunningServicesFromCommand(
|
||||
'docker',
|
||||
['ps', '--format', '{{.Names}}'],
|
||||
'docker ps',
|
||||
);
|
||||
|
||||
return supabaseDockerResult;
|
||||
}
|
||||
|
||||
private async tryGetRunningServicesFromCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
source: string,
|
||||
): Promise<{ running: boolean; source?: string; details?: string }> {
|
||||
try {
|
||||
const result = await this.deps.executeCommand(command, args);
|
||||
const serviceLines = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const running = serviceLines.some((line) =>
|
||||
/(mailpit|inbucket)/.test(line),
|
||||
);
|
||||
|
||||
return {
|
||||
running,
|
||||
source,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return {
|
||||
running: false,
|
||||
source,
|
||||
details: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkMailboxApi(url: string): Promise<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await this.deps.fetchJson(url);
|
||||
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSupabaseExtras() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('pnpm', [
|
||||
'--filter',
|
||||
'web',
|
||||
'supabase',
|
||||
'status',
|
||||
'-o',
|
||||
'env',
|
||||
]);
|
||||
|
||||
return parseSupabaseEnvOutput(result.stdout);
|
||||
} catch {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('pnpm', [
|
||||
'--filter',
|
||||
'web',
|
||||
'supabase:status',
|
||||
]);
|
||||
|
||||
return parseSupabaseTextOutput(result.stdout);
|
||||
} catch {
|
||||
return {
|
||||
api_url: undefined,
|
||||
studio_url: undefined,
|
||||
anon_key: undefined,
|
||||
service_role_key: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async stopProcess(pid: number) {
|
||||
try {
|
||||
await this.deps.killProcess(pid, 'SIGTERM');
|
||||
await this.deps.sleep(200);
|
||||
const running = await this.deps.isProcessRunning(pid);
|
||||
|
||||
if (running) {
|
||||
await this.deps.killProcess(pid, 'SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
// noop - process may already be dead.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseSupabaseEnvOutput(output: string) {
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
|
||||
if (key) {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
api_url: values.API_URL,
|
||||
studio_url: values.STUDIO_URL,
|
||||
anon_key: values.ANON_KEY,
|
||||
service_role_key: values.SERVICE_ROLE_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
function extractPortFromUrl(url: string | undefined): number | undefined {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const port = Number(parsed.port);
|
||||
|
||||
return Number.isFinite(port) && port > 0 ? port : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSupabaseTextOutput(output: string) {
|
||||
const findValue = (label: string) => {
|
||||
const regex = new RegExp(`${label}\\s*:\\s*(.+)`);
|
||||
const match = output.match(regex);
|
||||
return match?.[1]?.trim();
|
||||
};
|
||||
|
||||
return {
|
||||
api_url: findValue('API URL'),
|
||||
studio_url: findValue('Studio URL'),
|
||||
anon_key: findValue('anon key'),
|
||||
service_role_key: findValue('service_role key'),
|
||||
};
|
||||
}
|
||||
|
||||
function extractForwardToUrl(command: string): string | undefined {
|
||||
const match = command.match(/--forward-to\s+(\S+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
69
packages/mcp-server/src/tools/dev/schema.ts
Normal file
69
packages/mcp-server/src/tools/dev/schema.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const DevServiceIdSchema = z.enum(['app', 'database', 'stripe', 'mailbox']);
|
||||
const DevServiceSelectionSchema = z.enum([
|
||||
'all',
|
||||
'app',
|
||||
'database',
|
||||
'stripe',
|
||||
'mailbox',
|
||||
'mailpit',
|
||||
]);
|
||||
|
||||
const DevServiceStatusItemSchema = z.object({
|
||||
id: DevServiceIdSchema,
|
||||
name: z.string(),
|
||||
status: z.enum(['running', 'stopped', 'error']),
|
||||
port: z.number().nullable().optional(),
|
||||
url: z.string().optional(),
|
||||
pid: z.number().nullable().optional(),
|
||||
webhook_url: z.string().optional(),
|
||||
extras: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const KitDevStartInputSchema = z.object({
|
||||
services: z.array(DevServiceSelectionSchema).min(1).default(['all']),
|
||||
});
|
||||
|
||||
export const KitDevStartOutputSchema = z.object({
|
||||
services: z.array(DevServiceStatusItemSchema),
|
||||
});
|
||||
|
||||
export const KitDevStopInputSchema = z.object({
|
||||
services: z.array(DevServiceSelectionSchema).min(1).default(['all']),
|
||||
});
|
||||
|
||||
export const KitDevStopOutputSchema = z.object({
|
||||
stopped: z.array(DevServiceIdSchema),
|
||||
});
|
||||
|
||||
export const KitDevStatusInputSchema = z.object({});
|
||||
|
||||
export const KitDevStatusOutputSchema = z.object({
|
||||
services: z.array(DevServiceStatusItemSchema),
|
||||
});
|
||||
|
||||
export const KitMailboxStatusInputSchema = z.object({});
|
||||
|
||||
export const KitMailboxStatusOutputSchema = z.object({
|
||||
connected: z.boolean(),
|
||||
running: z.boolean(),
|
||||
api_reachable: z.boolean(),
|
||||
url: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DevServiceId = z.infer<typeof DevServiceIdSchema>;
|
||||
export type DevServiceSelection = z.infer<typeof DevServiceSelectionSchema>;
|
||||
export type DevServiceStatusItem = z.infer<typeof DevServiceStatusItemSchema>;
|
||||
export type KitDevStartInput = z.infer<typeof KitDevStartInputSchema>;
|
||||
export type KitDevStartOutput = z.infer<typeof KitDevStartOutputSchema>;
|
||||
export type KitDevStopInput = z.infer<typeof KitDevStopInputSchema>;
|
||||
export type KitDevStopOutput = z.infer<typeof KitDevStopOutputSchema>;
|
||||
export type KitDevStatusInput = z.infer<typeof KitDevStatusInputSchema>;
|
||||
export type KitDevStatusOutput = z.infer<typeof KitDevStatusOutputSchema>;
|
||||
export type KitMailboxStatusInput = z.infer<typeof KitMailboxStatusInputSchema>;
|
||||
export type KitMailboxStatusOutput = z.infer<
|
||||
typeof KitMailboxStatusOutputSchema
|
||||
>;
|
||||
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type KitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from '../kit-emails.service';
|
||||
|
||||
function createDeps(
|
||||
files: Record<string, string>,
|
||||
directories: string[],
|
||||
): KitEmailsDeps {
|
||||
const store = { ...files };
|
||||
const dirSet = new Set(directories);
|
||||
|
||||
return {
|
||||
rootPath: '/repo',
|
||||
async readFile(filePath: string) {
|
||||
if (!(filePath in store)) {
|
||||
const error = new Error(
|
||||
`ENOENT: no such file: ${filePath}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return store[filePath]!;
|
||||
},
|
||||
async readdir(dirPath: string) {
|
||||
if (!dirSet.has(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(store)
|
||||
.filter((p) => {
|
||||
const parent = p.substring(0, p.lastIndexOf('/'));
|
||||
return parent === dirPath;
|
||||
})
|
||||
.map((p) => p.substring(p.lastIndexOf('/') + 1));
|
||||
},
|
||||
async fileExists(filePath: string) {
|
||||
return filePath in store || dirSet.has(filePath);
|
||||
},
|
||||
async renderReactEmail() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const REACT_DIR = '/repo/packages/email-templates/src/emails';
|
||||
const SUPABASE_DIR = '/repo/apps/web/supabase/templates';
|
||||
|
||||
describe('KitEmailsService.list', () => {
|
||||
it('discovers React Email templates with -email suffix in id', async () => {
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${REACT_DIR}/invite.email.tsx`]:
|
||||
'export function renderInviteEmail() {}',
|
||||
[`${REACT_DIR}/otp.email.tsx`]: 'export function renderOtpEmail() {}',
|
||||
},
|
||||
[REACT_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.list();
|
||||
|
||||
expect(result.templates).toHaveLength(2);
|
||||
expect(result.categories).toEqual(['transactional']);
|
||||
expect(result.total).toBe(2);
|
||||
|
||||
const invite = result.templates.find((t) => t.id === 'invite-email');
|
||||
|
||||
expect(invite).toBeDefined();
|
||||
expect(invite!.name).toBe('Invite');
|
||||
expect(invite!.category).toBe('transactional');
|
||||
expect(invite!.file).toBe(
|
||||
'packages/email-templates/src/emails/invite.email.tsx',
|
||||
);
|
||||
|
||||
const otp = result.templates.find((t) => t.id === 'otp-email');
|
||||
|
||||
expect(otp).toBeDefined();
|
||||
expect(otp!.name).toBe('Otp');
|
||||
});
|
||||
|
||||
it('discovers Supabase Auth HTML templates', async () => {
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${SUPABASE_DIR}/magic-link.html`]: '<html>magic</html>',
|
||||
[`${SUPABASE_DIR}/reset-password.html`]: '<html>reset</html>',
|
||||
},
|
||||
[SUPABASE_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.list();
|
||||
|
||||
expect(result.templates).toHaveLength(2);
|
||||
expect(result.categories).toEqual(['supabase-auth']);
|
||||
|
||||
const magicLink = result.templates.find((t) => t.id === 'magic-link');
|
||||
|
||||
expect(magicLink).toBeDefined();
|
||||
expect(magicLink!.name).toBe('Magic Link');
|
||||
expect(magicLink!.category).toBe('supabase-auth');
|
||||
expect(magicLink!.file).toBe('apps/web/supabase/templates/magic-link.html');
|
||||
});
|
||||
|
||||
it('discovers both types and returns sorted categories', async () => {
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${REACT_DIR}/invite.email.tsx`]:
|
||||
'export function renderInviteEmail() {}',
|
||||
[`${SUPABASE_DIR}/confirm-email.html`]: '<html>confirm</html>',
|
||||
},
|
||||
[REACT_DIR, SUPABASE_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.list();
|
||||
|
||||
expect(result.templates).toHaveLength(2);
|
||||
expect(result.categories).toEqual(['supabase-auth', 'transactional']);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it('handles empty directories gracefully', async () => {
|
||||
const deps = createDeps({}, []);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.list();
|
||||
|
||||
expect(result.templates).toEqual([]);
|
||||
expect(result.categories).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores non-email files in the directories', async () => {
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${REACT_DIR}/invite.email.tsx`]:
|
||||
'export function renderInviteEmail() {}',
|
||||
[`${REACT_DIR}/utils.ts`]: 'export const helper = true;',
|
||||
[`${REACT_DIR}/README.md`]: '# readme',
|
||||
[`${SUPABASE_DIR}/magic-link.html`]: '<html>magic</html>',
|
||||
[`${SUPABASE_DIR}/config.json`]: '{}',
|
||||
},
|
||||
[REACT_DIR, SUPABASE_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.list();
|
||||
|
||||
expect(result.templates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('avoids id collision between React otp-email and Supabase otp', async () => {
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${REACT_DIR}/otp.email.tsx`]: 'export function renderOtpEmail() {}',
|
||||
[`${SUPABASE_DIR}/otp.html`]: '<html>otp</html>',
|
||||
},
|
||||
[REACT_DIR, SUPABASE_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.list();
|
||||
|
||||
const ids = result.templates.map((t) => t.id);
|
||||
|
||||
expect(ids).toContain('otp-email');
|
||||
expect(ids).toContain('otp');
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KitEmailsService.read', () => {
|
||||
it('reads a React Email template and extracts props', async () => {
|
||||
const source = `
|
||||
interface Props {
|
||||
teamName: string;
|
||||
teamLogo?: string;
|
||||
inviter: string | undefined;
|
||||
invitedUserEmail: string;
|
||||
link: string;
|
||||
productName: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export async function renderInviteEmail(props: Props) {}
|
||||
`;
|
||||
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${REACT_DIR}/invite.email.tsx`]: source,
|
||||
},
|
||||
[REACT_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.read({ id: 'invite-email' });
|
||||
|
||||
expect(result.id).toBe('invite-email');
|
||||
expect(result.name).toBe('Invite');
|
||||
expect(result.category).toBe('transactional');
|
||||
expect(result.source).toBe(source);
|
||||
|
||||
expect(result.props).toEqual([
|
||||
{ name: 'teamName', type: 'string', required: true },
|
||||
{ name: 'teamLogo', type: 'string', required: false },
|
||||
{ name: 'inviter', type: 'string | undefined', required: true },
|
||||
{ name: 'invitedUserEmail', type: 'string', required: true },
|
||||
{ name: 'link', type: 'string', required: true },
|
||||
{ name: 'productName', type: 'string', required: true },
|
||||
{ name: 'language', type: 'string', required: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a Supabase HTML template with empty props', async () => {
|
||||
const html = '<html><body>Magic Link</body></html>';
|
||||
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${SUPABASE_DIR}/magic-link.html`]: html,
|
||||
},
|
||||
[SUPABASE_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.read({ id: 'magic-link' });
|
||||
|
||||
expect(result.id).toBe('magic-link');
|
||||
expect(result.source).toBe(html);
|
||||
expect(result.props).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws for unknown template id', async () => {
|
||||
const deps = createDeps({}, []);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
|
||||
await expect(service.read({ id: 'nonexistent' })).rejects.toThrow(
|
||||
'Email template not found: "nonexistent"',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles templates without Props interface', async () => {
|
||||
const source =
|
||||
'export async function renderSimpleEmail() { return { html: "" }; }';
|
||||
|
||||
const deps = createDeps(
|
||||
{
|
||||
[`${REACT_DIR}/simple.email.tsx`]: source,
|
||||
},
|
||||
[REACT_DIR],
|
||||
);
|
||||
|
||||
const service = createKitEmailsService(deps);
|
||||
const result = await service.read({ id: 'simple-email' });
|
||||
|
||||
expect(result.props).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path safety', () => {
|
||||
it('rejects ids with path traversal', async () => {
|
||||
const deps = createDeps({}, []);
|
||||
const service = createKitEmailsService(deps);
|
||||
|
||||
await expect(service.read({ id: '../etc/passwd' })).rejects.toThrow(
|
||||
'Template id must not contain ".."',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects ids with forward slashes', async () => {
|
||||
const deps = createDeps({}, []);
|
||||
const service = createKitEmailsService(deps);
|
||||
|
||||
await expect(service.read({ id: 'foo/bar' })).rejects.toThrow(
|
||||
'Template id must not include path separators',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects ids with backslashes', async () => {
|
||||
const deps = createDeps({}, []);
|
||||
const service = createKitEmailsService(deps);
|
||||
|
||||
await expect(service.read({ id: 'foo\\bar' })).rejects.toThrow(
|
||||
'Template id must not include path separators',
|
||||
);
|
||||
});
|
||||
});
|
||||
109
packages/mcp-server/src/tools/emails/index.ts
Normal file
109
packages/mcp-server/src/tools/emails/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
import {
|
||||
type KitEmailsDeps,
|
||||
createKitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from './kit-emails.service';
|
||||
import {
|
||||
KitEmailsListInputSchema,
|
||||
KitEmailsListOutputSchema,
|
||||
KitEmailsReadInputSchema,
|
||||
KitEmailsReadOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
type TextContent = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function registerKitEmailTemplatesTools(server: McpServer) {
|
||||
const service = createKitEmailsService(createKitEmailsDeps());
|
||||
|
||||
server.registerTool(
|
||||
'kit_email_templates_list',
|
||||
{
|
||||
description:
|
||||
'List project email template files (React Email + Supabase auth templates), not received inbox messages',
|
||||
inputSchema: KitEmailsListInputSchema,
|
||||
outputSchema: KitEmailsListOutputSchema,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const result = await service.list();
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_email_templates_list', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_email_templates_read',
|
||||
{
|
||||
description:
|
||||
'Read a project email template source file by template id, with extracted props and optional rendered HTML sample',
|
||||
inputSchema: KitEmailsReadInputSchema,
|
||||
outputSchema: KitEmailsReadOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const { id } = KitEmailsReadInputSchema.parse(input);
|
||||
const result = await service.read({ id });
|
||||
|
||||
const content: TextContent[] = [];
|
||||
|
||||
// Return source, props, and metadata
|
||||
const { renderedHtml, ...metadata } = result;
|
||||
|
||||
content.push({ type: 'text', text: JSON.stringify(metadata) });
|
||||
|
||||
// Include rendered HTML as a separate content block
|
||||
if (renderedHtml) {
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: `\n\n--- Rendered HTML ---\n\n${renderedHtml}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_email_templates_read', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const registerKitEmailsTools = registerKitEmailTemplatesTools;
|
||||
|
||||
function buildErrorResponse(tool: string, error: unknown) {
|
||||
const message = `${tool} failed: ${toErrorMessage(error)}`;
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
content: buildTextContent(message),
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
function buildTextContent(text: string): TextContent[] {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
export { createKitEmailsService, createKitEmailsDeps };
|
||||
export type { KitEmailsDeps };
|
||||
export type { KitEmailsListOutput, KitEmailsReadOutput } from './schema';
|
||||
289
packages/mcp-server/src/tools/emails/kit-emails.service.ts
Normal file
289
packages/mcp-server/src/tools/emails/kit-emails.service.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { EMAIL_TEMPLATE_RENDERERS } from '@kit/email-templates/registry';
|
||||
|
||||
import type { KitEmailsListOutput, KitEmailsReadOutput } from './schema';
|
||||
|
||||
export interface KitEmailsDeps {
|
||||
rootPath: string;
|
||||
readFile(filePath: string): Promise<string>;
|
||||
readdir(dirPath: string): Promise<string[]>;
|
||||
fileExists(filePath: string): Promise<boolean>;
|
||||
renderReactEmail(
|
||||
sampleProps: Record<string, string>,
|
||||
templateId: string,
|
||||
): Promise<string | null>;
|
||||
}
|
||||
|
||||
interface EmailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
file: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function createKitEmailsService(deps: KitEmailsDeps) {
|
||||
return new KitEmailsService(deps);
|
||||
}
|
||||
|
||||
export class KitEmailsService {
|
||||
constructor(private readonly deps: KitEmailsDeps) {}
|
||||
|
||||
async list(): Promise<KitEmailsListOutput> {
|
||||
const templates: EmailTemplate[] = [];
|
||||
|
||||
const reactTemplates = await this.discoverReactEmailTemplates();
|
||||
const supabaseTemplates = await this.discoverSupabaseAuthTemplates();
|
||||
|
||||
templates.push(...reactTemplates, ...supabaseTemplates);
|
||||
|
||||
const categories = [...new Set(templates.map((t) => t.category))].sort();
|
||||
|
||||
return {
|
||||
templates,
|
||||
categories,
|
||||
total: templates.length,
|
||||
};
|
||||
}
|
||||
|
||||
async read(input: { id: string }): Promise<KitEmailsReadOutput> {
|
||||
assertSafeId(input.id);
|
||||
|
||||
const { templates } = await this.list();
|
||||
const template = templates.find((t) => t.id === input.id);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Email template not found: "${input.id}"`);
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(this.deps.rootPath, template.file);
|
||||
ensureInsideRoot(absolutePath, this.deps.rootPath, input.id);
|
||||
|
||||
const source = await this.deps.readFile(absolutePath);
|
||||
const isReactEmail = absolutePath.includes('packages/email-templates');
|
||||
const props = isReactEmail ? extractPropsFromSource(source) : [];
|
||||
|
||||
let renderedHtml: string | null = null;
|
||||
|
||||
if (isReactEmail) {
|
||||
const sampleProps = buildSampleProps(props);
|
||||
|
||||
renderedHtml = await this.deps.renderReactEmail(sampleProps, template.id);
|
||||
}
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
category: template.category,
|
||||
file: template.file,
|
||||
source,
|
||||
props,
|
||||
renderedHtml,
|
||||
};
|
||||
}
|
||||
|
||||
private async discoverReactEmailTemplates(): Promise<EmailTemplate[]> {
|
||||
const dir = path.join('packages', 'email-templates', 'src', 'emails');
|
||||
|
||||
const absoluteDir = path.resolve(this.deps.rootPath, dir);
|
||||
|
||||
if (!(await this.deps.fileExists(absoluteDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await this.deps.readdir(absoluteDir);
|
||||
const templates: EmailTemplate[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.email.tsx')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stem = file.replace(/\.email\.tsx$/, '');
|
||||
const id = `${stem}-email`;
|
||||
const name = humanize(stem);
|
||||
|
||||
templates.push({
|
||||
id,
|
||||
name,
|
||||
category: 'transactional',
|
||||
file: path.join(dir, file),
|
||||
description: `${name} transactional email template`,
|
||||
});
|
||||
}
|
||||
|
||||
return templates.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
private async discoverSupabaseAuthTemplates(): Promise<EmailTemplate[]> {
|
||||
const dir = path.join('apps', 'web', 'supabase', 'templates');
|
||||
|
||||
const absoluteDir = path.resolve(this.deps.rootPath, dir);
|
||||
|
||||
if (!(await this.deps.fileExists(absoluteDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await this.deps.readdir(absoluteDir);
|
||||
const templates: EmailTemplate[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = file.replace(/\.html$/, '');
|
||||
const name = humanize(id);
|
||||
|
||||
templates.push({
|
||||
id,
|
||||
name,
|
||||
category: 'supabase-auth',
|
||||
file: path.join(dir, file),
|
||||
description: `${name} Supabase auth email template`,
|
||||
});
|
||||
}
|
||||
|
||||
return templates.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
}
|
||||
|
||||
function humanize(kebab: string): string {
|
||||
return kebab
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function extractPropsFromSource(
|
||||
source: string,
|
||||
): Array<{ name: string; type: string; required: boolean }> {
|
||||
const interfaceMatch = source.match(/interface\s+Props\s*\{([^}]*)\}/);
|
||||
|
||||
if (!interfaceMatch?.[1]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const body = interfaceMatch[1];
|
||||
const props: Array<{ name: string; type: string; required: boolean }> = [];
|
||||
|
||||
const propRegex = /(\w+)(\??):\s*([^;\n]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = propRegex.exec(body)) !== null) {
|
||||
const name = match[1]!;
|
||||
const optional = match[2] === '?';
|
||||
const type = match[3]!.trim();
|
||||
|
||||
props.push({
|
||||
name,
|
||||
type,
|
||||
required: !optional,
|
||||
});
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
function ensureInsideRoot(resolved: string, root: string, input: string) {
|
||||
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
||||
|
||||
if (!resolved.startsWith(normalizedRoot) && resolved !== root) {
|
||||
throw new Error(
|
||||
`Invalid path: "${input}" resolves outside the project root`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildSampleProps(
|
||||
props: Array<{ name: string; type: string; required: boolean }>,
|
||||
): Record<string, string> {
|
||||
const sample: Record<string, string> = {};
|
||||
|
||||
for (const prop of props) {
|
||||
if (prop.name === 'language') continue;
|
||||
|
||||
sample[prop.name] = SAMPLE_PROP_VALUES[prop.name] ?? `Sample ${prop.name}`;
|
||||
}
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
const SAMPLE_PROP_VALUES: Record<string, string> = {
|
||||
productName: 'Makerkit',
|
||||
teamName: 'Acme Team',
|
||||
inviter: 'John Doe',
|
||||
invitedUserEmail: 'user@example.com',
|
||||
link: 'https://example.com/action',
|
||||
otp: '123456',
|
||||
email: 'user@example.com',
|
||||
name: 'Jane Doe',
|
||||
userName: 'Jane Doe',
|
||||
};
|
||||
|
||||
function assertSafeId(id: string) {
|
||||
if (id.includes('..')) {
|
||||
throw new Error('Template id must not contain ".."');
|
||||
}
|
||||
|
||||
if (id.includes('/') || id.includes('\\')) {
|
||||
throw new Error('Template id must not include path separators');
|
||||
}
|
||||
}
|
||||
|
||||
export function createKitEmailsDeps(rootPath = process.cwd()): KitEmailsDeps {
|
||||
return {
|
||||
rootPath,
|
||||
async readFile(filePath: string) {
|
||||
const fs = await import('node:fs/promises');
|
||||
return fs.readFile(filePath, 'utf8');
|
||||
},
|
||||
async readdir(dirPath: string) {
|
||||
const fs = await import('node:fs/promises');
|
||||
return fs.readdir(dirPath);
|
||||
},
|
||||
async fileExists(filePath: string) {
|
||||
const fs = await import('node:fs/promises');
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async renderReactEmail(
|
||||
sampleProps: Record<string, string>,
|
||||
templateId?: string,
|
||||
) {
|
||||
const renderFromRegistry =
|
||||
typeof templateId === 'string'
|
||||
? EMAIL_TEMPLATE_RENDERERS?.[templateId]
|
||||
: undefined;
|
||||
|
||||
if (typeof renderFromRegistry === 'function') {
|
||||
const result = await renderFromRegistry(sampleProps);
|
||||
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'html' in result &&
|
||||
typeof (result as { html: unknown }).html === 'string'
|
||||
) {
|
||||
return (result as { html: string }).html;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(`Email template renderer not found: "${templateId}"`);
|
||||
},
|
||||
};
|
||||
}
|
||||
46
packages/mcp-server/src/tools/emails/schema.ts
Normal file
46
packages/mcp-server/src/tools/emails/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export const KitEmailsListInputSchema = z.object({});
|
||||
|
||||
const EmailTemplateSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
category: z.string(),
|
||||
file: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
const KitEmailsListSuccessOutputSchema = z.object({
|
||||
templates: z.array(EmailTemplateSchema),
|
||||
categories: z.array(z.string()),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
export const KitEmailsListOutputSchema = KitEmailsListSuccessOutputSchema;
|
||||
|
||||
export const KitEmailsReadInputSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const PropSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
required: z.boolean(),
|
||||
});
|
||||
|
||||
const KitEmailsReadSuccessOutputSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
category: z.string(),
|
||||
file: z.string(),
|
||||
source: z.string(),
|
||||
props: z.array(PropSchema),
|
||||
renderedHtml: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const KitEmailsReadOutputSchema = KitEmailsReadSuccessOutputSchema;
|
||||
|
||||
export type KitEmailsListInput = z.infer<typeof KitEmailsListInputSchema>;
|
||||
export type KitEmailsListOutput = z.infer<typeof KitEmailsListOutputSchema>;
|
||||
export type KitEmailsReadInput = z.infer<typeof KitEmailsReadInputSchema>;
|
||||
export type KitEmailsReadOutput = z.infer<typeof KitEmailsReadOutputSchema>;
|
||||
845
packages/mcp-server/src/tools/env/__tests__/kit-env.service.test.ts
vendored
Normal file
845
packages/mcp-server/src/tools/env/__tests__/kit-env.service.test.ts
vendored
Normal file
@@ -0,0 +1,845 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type KitEnvDeps, createKitEnvService } from '../kit-env.service';
|
||||
import { processEnvDefinitions } from '../scanner';
|
||||
import { KitEnvUpdateInputSchema } from '../schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createDeps(
|
||||
files: Record<string, string> = {},
|
||||
overrides: Partial<KitEnvDeps> = {},
|
||||
): KitEnvDeps & { _store: Record<string, string> } {
|
||||
const store = { ...files };
|
||||
|
||||
return {
|
||||
rootPath: '/repo',
|
||||
async readFile(filePath: string) {
|
||||
if (!(filePath in store)) {
|
||||
const error = new Error(
|
||||
`ENOENT: no such file: ${filePath}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return store[filePath]!;
|
||||
},
|
||||
async writeFile(filePath: string, content: string) {
|
||||
store[filePath] = content;
|
||||
},
|
||||
async fileExists(filePath: string) {
|
||||
return filePath in store;
|
||||
},
|
||||
...overrides,
|
||||
get _store() {
|
||||
return store;
|
||||
},
|
||||
} as KitEnvDeps & { _store: Record<string, string> };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('KitEnvService.getSchema', () => {
|
||||
it('returns grouped env variables with expected shape', async () => {
|
||||
const service = createKitEnvService(createDeps());
|
||||
const result = await service.getSchema();
|
||||
|
||||
expect(result.groups.length).toBeGreaterThan(0);
|
||||
|
||||
for (const group of result.groups) {
|
||||
expect(group.name).toBeTruthy();
|
||||
expect(group.description).toBeTruthy();
|
||||
expect(group.variables.length).toBeGreaterThan(0);
|
||||
|
||||
for (const variable of group.variables) {
|
||||
expect(variable.key).toBeTruthy();
|
||||
expect(typeof variable.required).toBe('boolean');
|
||||
expect(typeof variable.sensitive).toBe('boolean');
|
||||
expect(
|
||||
['string', 'url', 'email', 'number', 'boolean', 'enum'].includes(
|
||||
variable.type,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('includes Stripe variables with dependency metadata', async () => {
|
||||
const service = createKitEnvService(createDeps());
|
||||
const result = await service.getSchema();
|
||||
|
||||
const billingGroup = result.groups.find((g) => g.name === 'Billing');
|
||||
expect(billingGroup).toBeDefined();
|
||||
|
||||
const stripeSecret = billingGroup!.variables.find(
|
||||
(v) => v.key === 'STRIPE_SECRET_KEY',
|
||||
);
|
||||
expect(stripeSecret).toBeDefined();
|
||||
expect(stripeSecret!.sensitive).toBe(true);
|
||||
expect(stripeSecret!.dependencies).toBeDefined();
|
||||
expect(stripeSecret!.dependencies!.length).toBeGreaterThan(0);
|
||||
expect(stripeSecret!.dependencies![0]!.variable).toBe(
|
||||
'NEXT_PUBLIC_BILLING_PROVIDER',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('KitEnvService.update', () => {
|
||||
it('replaces an existing key in-place', async () => {
|
||||
const deps = createDeps({
|
||||
'/repo/apps/web/.env.local':
|
||||
'# Comment\nEMAIL_SENDER=team@example.com\nOTHER=foo\n',
|
||||
});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
const result = await service.update({
|
||||
key: 'EMAIL_SENDER',
|
||||
value: 'hello@example.com',
|
||||
file: '.env.local',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const content = deps._store['/repo/apps/web/.env.local']!;
|
||||
expect(content).toContain('EMAIL_SENDER=hello@example.com');
|
||||
// preserves comment
|
||||
expect(content).toContain('# Comment');
|
||||
// preserves other keys
|
||||
expect(content).toContain('OTHER=foo');
|
||||
});
|
||||
|
||||
it('appends new key when it does not exist', async () => {
|
||||
const deps = createDeps({
|
||||
'/repo/apps/web/.env.local': 'EXISTING=value\n',
|
||||
});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
await service.update({
|
||||
key: 'NEW_KEY',
|
||||
value: 'new_value',
|
||||
file: '.env.local',
|
||||
});
|
||||
|
||||
const content = deps._store['/repo/apps/web/.env.local']!;
|
||||
expect(content).toContain('EXISTING=value');
|
||||
expect(content).toContain('NEW_KEY=new_value');
|
||||
});
|
||||
|
||||
it('creates file if it does not exist', async () => {
|
||||
const deps = createDeps({});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
await service.update({
|
||||
key: 'BRAND_NEW',
|
||||
value: 'value',
|
||||
file: '.env.local',
|
||||
});
|
||||
|
||||
const content = deps._store['/repo/apps/web/.env.local']!;
|
||||
expect(content).toContain('BRAND_NEW=value');
|
||||
});
|
||||
|
||||
it('throws when key is missing', async () => {
|
||||
const service = createKitEnvService(createDeps());
|
||||
|
||||
await expect(service.update({ value: 'v' })).rejects.toThrow(
|
||||
'Both key and value are required',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when value is missing', async () => {
|
||||
const service = createKitEnvService(createDeps());
|
||||
|
||||
await expect(service.update({ key: 'FOO' })).rejects.toThrow(
|
||||
'Both key and value are required',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves default file for secret key in development mode', async () => {
|
||||
const deps = createDeps({});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
// STRIPE_SECRET_KEY is marked as secret in the model
|
||||
await service.update({
|
||||
key: 'STRIPE_SECRET_KEY',
|
||||
value: 'sk_test_123',
|
||||
mode: 'development',
|
||||
});
|
||||
|
||||
// Secret keys default to .env.local in dev mode
|
||||
expect(deps._store['/repo/apps/web/.env.local']).toContain(
|
||||
'STRIPE_SECRET_KEY=sk_test_123',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves default file for key without explicit secret flag (defaults to secret)', async () => {
|
||||
const deps = createDeps({});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
// NEXT_PUBLIC_SITE_URL has no explicit `secret` field in the model.
|
||||
// resolveDefaultFile defaults unknown to secret=true (conservative),
|
||||
// so it should go to .env.local in development mode.
|
||||
await service.update({
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'http://localhost:3000',
|
||||
mode: 'development',
|
||||
});
|
||||
|
||||
expect(deps._store['/repo/apps/web/.env.local']).toContain(
|
||||
'NEXT_PUBLIC_SITE_URL=http://localhost:3000',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves default file for secret key in production mode', async () => {
|
||||
const deps = createDeps({});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
await service.update({
|
||||
key: 'STRIPE_SECRET_KEY',
|
||||
value: 'sk_live_abc',
|
||||
mode: 'production',
|
||||
});
|
||||
|
||||
// Secret keys in prod default to .env.production.local
|
||||
expect(deps._store['/repo/apps/web/.env.production.local']).toContain(
|
||||
'STRIPE_SECRET_KEY=sk_live_abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not default file in MCP schema', () => {
|
||||
const parsed = KitEnvUpdateInputSchema.parse({
|
||||
key: 'FOO',
|
||||
value: 'bar',
|
||||
mode: 'production',
|
||||
});
|
||||
|
||||
expect(parsed.file).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path traversal prevention
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('KitEnvService — path traversal prevention', () => {
|
||||
it('rejects file paths that traverse outside web directory', async () => {
|
||||
const service = createKitEnvService(createDeps());
|
||||
|
||||
await expect(service.rawRead('../../../../etc/passwd')).rejects.toThrow(
|
||||
'resolves outside the web app directory',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects rawWrite with traversal path', async () => {
|
||||
const service = createKitEnvService(createDeps());
|
||||
|
||||
await expect(
|
||||
service.rawWrite('../../../etc/evil', 'malicious'),
|
||||
).rejects.toThrow('resolves outside the web app directory');
|
||||
});
|
||||
|
||||
it('rejects update with traversal file path', async () => {
|
||||
const service = createKitEnvService(createDeps());
|
||||
|
||||
await expect(
|
||||
service.update({ key: 'FOO', value: 'bar', file: '../../.env' }),
|
||||
).rejects.toThrow('resolves outside the web app directory');
|
||||
});
|
||||
|
||||
it('allows valid file names within web directory', async () => {
|
||||
const deps = createDeps({
|
||||
'/repo/apps/web/.env.local': 'KEY=val',
|
||||
});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
const result = await service.rawRead('.env.local');
|
||||
expect(result.exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rawRead / rawWrite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('KitEnvService.rawRead', () => {
|
||||
it('returns content when file exists', async () => {
|
||||
const service = createKitEnvService(
|
||||
createDeps({
|
||||
'/repo/apps/web/.env.local': '# My env\nFOO=bar\n',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.rawRead('.env.local');
|
||||
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.content).toBe('# My env\nFOO=bar\n');
|
||||
});
|
||||
|
||||
it('returns empty + exists:false when file missing', async () => {
|
||||
const service = createKitEnvService(createDeps({}));
|
||||
|
||||
const result = await service.rawRead('.env.local');
|
||||
|
||||
expect(result.exists).toBe(false);
|
||||
expect(result.content).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('KitEnvService.rawWrite', () => {
|
||||
it('overwrites file with raw content', async () => {
|
||||
const deps = createDeps({
|
||||
'/repo/apps/web/.env.local': 'OLD=content',
|
||||
});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
const result = await service.rawWrite('.env.local', '# New\nNEW=value');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deps._store['/repo/apps/web/.env.local']).toBe('# New\nNEW=value');
|
||||
});
|
||||
|
||||
it('creates file when it does not exist', async () => {
|
||||
const deps = createDeps({});
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
|
||||
await service.rawWrite('.env.production', 'PROD_KEY=val');
|
||||
|
||||
expect(deps._store['/repo/apps/web/.env.production']).toBe('PROD_KEY=val');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read — mode-based file precedence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('KitEnvService.read — file precedence', () => {
|
||||
it('returns variables with mode information when pointing to real workspace', async () => {
|
||||
// Use the actual monorepo root — will scan real .env files
|
||||
const service = createKitEnvService(
|
||||
createDeps(
|
||||
{},
|
||||
{ rootPath: process.cwd().replace(/\/packages\/mcp-server$/, '') },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await service.read('development');
|
||||
|
||||
expect(result.mode).toBe('development');
|
||||
expect(typeof result.variables).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAppState / getVariable — injected fs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('KitEnvService — injected fs', () => {
|
||||
it('getAppState reads from injected fs', async () => {
|
||||
const deps = createDeps(
|
||||
{
|
||||
'/repo/apps/web/.env': 'FOO=bar\n',
|
||||
},
|
||||
{
|
||||
readdir: async (dirPath: string) => {
|
||||
if (dirPath === '/repo/apps') {
|
||||
return ['web'];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
stat: async (path: string) => ({
|
||||
isDirectory: () => path === '/repo/apps/web',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
const states = await service.getAppState('development');
|
||||
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0]!.variables['FOO']!.effectiveValue).toBe('bar');
|
||||
});
|
||||
|
||||
it('getVariable reads from injected fs', async () => {
|
||||
const deps = createDeps(
|
||||
{
|
||||
'/repo/apps/web/.env': 'HELLO=world\n',
|
||||
},
|
||||
{
|
||||
readdir: async (dirPath: string) => {
|
||||
if (dirPath === '/repo/apps') {
|
||||
return ['web'];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
stat: async (path: string) => ({
|
||||
isDirectory: () => path === '/repo/apps/web',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const service = createKitEnvService(deps);
|
||||
const value = await service.getVariable('HELLO', 'development');
|
||||
|
||||
expect(value).toBe('world');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processEnvDefinitions — override chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('processEnvDefinitions — override chains', () => {
|
||||
it('resolves override with development precedence (.env < .env.development < .env.local)', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://base.com',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://dev.com',
|
||||
source: '.env.development',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://local.com',
|
||||
source: '.env.local',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const variable = result.variables['NEXT_PUBLIC_SITE_URL'];
|
||||
|
||||
expect(variable).toBeDefined();
|
||||
// .env.local has highest precedence in development
|
||||
expect(variable!.effectiveValue).toBe('https://local.com');
|
||||
expect(variable!.effectiveSource).toBe('.env.local');
|
||||
expect(variable!.isOverridden).toBe(true);
|
||||
expect(variable!.definitions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('resolves override with production precedence (.env < .env.production < .env.local < .env.production.local)', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://base.com',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://prod.com',
|
||||
source: '.env.production',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://local.com',
|
||||
source: '.env.local',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://prod-local.com',
|
||||
source: '.env.production.local',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'production');
|
||||
const variable = result.variables['NEXT_PUBLIC_SITE_URL'];
|
||||
|
||||
expect(variable!.effectiveValue).toBe('https://prod-local.com');
|
||||
expect(variable!.effectiveSource).toBe('.env.production.local');
|
||||
expect(variable!.isOverridden).toBe(true);
|
||||
});
|
||||
|
||||
it('marks single-source variable as NOT overridden', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://site.com',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const variable = result.variables['NEXT_PUBLIC_SITE_URL'];
|
||||
|
||||
expect(variable!.isOverridden).toBe(false);
|
||||
expect(variable!.effectiveSource).toBe('.env');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processEnvDefinitions — conditional requirements (Stripe keys)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('processEnvDefinitions — conditional requirements', () => {
|
||||
it('flags STRIPE_SECRET_KEY as invalid when billing provider is stripe and key is missing', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
|
||||
value: 'stripe',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
|
||||
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
|
||||
expect(stripeKey).toBeDefined();
|
||||
expect(stripeKey!.effectiveSource).toBe('MISSING');
|
||||
expect(stripeKey!.validation.success).toBe(false);
|
||||
expect(stripeKey!.validation.error.issues.length).toBeGreaterThan(0);
|
||||
|
||||
// Regression guard: contextual message must be preserved, NOT replaced
|
||||
// by generic "required but missing"
|
||||
expect(
|
||||
stripeKey!.validation.error.issues.some((i) =>
|
||||
i.includes('NEXT_PUBLIC_BILLING_PROVIDER'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT flag STRIPE_SECRET_KEY when billing provider is lemon-squeezy', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
|
||||
value: 'lemon-squeezy',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
|
||||
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
|
||||
expect(stripeKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags LEMON_SQUEEZY_SECRET_KEY as invalid when provider is lemon-squeezy and key is missing', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
|
||||
value: 'lemon-squeezy',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
|
||||
const lsKey = result.variables['LEMON_SQUEEZY_SECRET_KEY'];
|
||||
expect(lsKey).toBeDefined();
|
||||
expect(lsKey!.effectiveSource).toBe('MISSING');
|
||||
expect(lsKey!.validation.success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates Stripe key format (must start with sk_ or rk_)', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
|
||||
value: 'stripe',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'STRIPE_SECRET_KEY',
|
||||
value: 'invalid_key_123',
|
||||
source: '.env.local',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
|
||||
|
||||
expect(stripeKey).toBeDefined();
|
||||
expect(stripeKey!.validation.success).toBe(false);
|
||||
expect(
|
||||
stripeKey!.validation.error.issues.some(
|
||||
(i) =>
|
||||
i.toLowerCase().includes('sk_') || i.toLowerCase().includes('rk_'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('passes Stripe key validation when key format is correct', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
|
||||
value: 'stripe',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'STRIPE_SECRET_KEY',
|
||||
value: 'sk_test_abc123',
|
||||
source: '.env.local',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
|
||||
value: 'pk_test_abc123',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'STRIPE_WEBHOOK_SECRET',
|
||||
value: 'whsec_abc123',
|
||||
source: '.env.local',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
|
||||
|
||||
expect(stripeKey!.validation.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processEnvDefinitions — cross-variable validations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('processEnvDefinitions — cross-variable validations', () => {
|
||||
it('flags SUPABASE_SERVICE_ROLE_KEY when same as ANON_KEY', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SUPABASE_URL',
|
||||
value: 'http://localhost:54321',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
|
||||
value: 'same-key',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'SUPABASE_SERVICE_ROLE_KEY',
|
||||
value: 'same-key',
|
||||
source: '.env.local',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const serviceKey = result.variables['SUPABASE_SERVICE_ROLE_KEY'];
|
||||
|
||||
expect(serviceKey).toBeDefined();
|
||||
expect(serviceKey!.validation.success).toBe(false);
|
||||
expect(
|
||||
serviceKey!.validation.error.issues.some((i) =>
|
||||
i.toLowerCase().includes('different'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('passes when SUPABASE_SERVICE_ROLE_KEY differs from ANON_KEY', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SUPABASE_URL',
|
||||
value: 'http://localhost:54321',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
|
||||
value: 'anon-key-123',
|
||||
source: '.env',
|
||||
},
|
||||
{
|
||||
key: 'SUPABASE_SERVICE_ROLE_KEY',
|
||||
value: 'service-key-456',
|
||||
source: '.env.local',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const serviceKey = result.variables['SUPABASE_SERVICE_ROLE_KEY'];
|
||||
|
||||
expect(serviceKey!.validation.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processEnvDefinitions — mode-aware URL validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('processEnvDefinitions — mode-aware validations', () => {
|
||||
it('accepts http:// SITE_URL in development mode', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'http://localhost:3000',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
|
||||
|
||||
expect(siteUrl!.validation.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects http:// SITE_URL in production mode', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'http://example.com',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'production');
|
||||
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
|
||||
|
||||
expect(siteUrl!.validation.success).toBe(false);
|
||||
expect(
|
||||
siteUrl!.validation.error.issues.some((i) =>
|
||||
i.toLowerCase().includes('https'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts https:// SITE_URL in production mode', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_SITE_URL',
|
||||
value: 'https://example.com',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'production');
|
||||
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
|
||||
|
||||
expect(siteUrl!.validation.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processEnvDefinitions — missing required variables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('processEnvDefinitions — missing required variables', () => {
|
||||
it('injects missing required variables with MISSING source', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
|
||||
value: 'stripe',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
|
||||
// NEXT_PUBLIC_SITE_URL is required and missing
|
||||
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
|
||||
expect(siteUrl).toBeDefined();
|
||||
expect(siteUrl!.effectiveSource).toBe('MISSING');
|
||||
expect(siteUrl!.validation.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processEnvDefinitions — CAPTCHA conditional dependency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('processEnvDefinitions — CAPTCHA conditional dependency', () => {
|
||||
it('flags CAPTCHA_SECRET_TOKEN as required when CAPTCHA_SITE_KEY is set', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [
|
||||
{
|
||||
key: 'NEXT_PUBLIC_CAPTCHA_SITE_KEY',
|
||||
value: 'cap_site_123',
|
||||
source: '.env',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
const captchaSecret = result.variables['CAPTCHA_SECRET_TOKEN'];
|
||||
|
||||
expect(captchaSecret).toBeDefined();
|
||||
expect(captchaSecret!.effectiveSource).toBe('MISSING');
|
||||
expect(captchaSecret!.validation.success).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT flag CAPTCHA_SECRET_TOKEN when CAPTCHA_SITE_KEY is empty/absent', () => {
|
||||
const envInfo = {
|
||||
appName: 'web',
|
||||
filePath: '/repo/apps/web',
|
||||
variables: [],
|
||||
};
|
||||
|
||||
const result = processEnvDefinitions(envInfo, 'development');
|
||||
|
||||
const captchaSecret = result.variables['CAPTCHA_SECRET_TOKEN'];
|
||||
expect(captchaSecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
177
packages/mcp-server/src/tools/env/index.ts
vendored
Normal file
177
packages/mcp-server/src/tools/env/index.ts
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
import {
|
||||
type KitEnvDeps,
|
||||
createKitEnvDeps,
|
||||
createKitEnvService,
|
||||
} from './kit-env.service';
|
||||
import {
|
||||
KitEnvRawReadInputSchema,
|
||||
KitEnvRawReadOutputSchema,
|
||||
KitEnvRawWriteInputSchema,
|
||||
KitEnvRawWriteOutputSchema,
|
||||
KitEnvReadInputSchema,
|
||||
KitEnvReadOutputSchema,
|
||||
KitEnvSchemaInputSchema,
|
||||
KitEnvSchemaOutputSchema,
|
||||
KitEnvUpdateInputSchema,
|
||||
KitEnvUpdateOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
type TextContent = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function registerKitEnvTools(server: McpServer) {
|
||||
const service = createKitEnvService(createKitEnvDeps());
|
||||
|
||||
server.registerTool(
|
||||
'kit_env_schema',
|
||||
{
|
||||
description: 'Return environment variable schema for this kit variant',
|
||||
inputSchema: KitEnvSchemaInputSchema,
|
||||
outputSchema: KitEnvSchemaOutputSchema,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const result = await service.getSchema();
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_env_schema', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_env_read',
|
||||
{
|
||||
description: 'Read environment variables and validation state',
|
||||
inputSchema: KitEnvReadInputSchema,
|
||||
outputSchema: KitEnvReadOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitEnvReadInputSchema.parse(input);
|
||||
const result = await service.read(parsed.mode);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_env_read', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_env_update',
|
||||
{
|
||||
description: 'Update one environment variable in a target .env file',
|
||||
inputSchema: KitEnvUpdateInputSchema,
|
||||
outputSchema: KitEnvUpdateOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitEnvUpdateInputSchema.parse(input);
|
||||
const result = await service.update(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_env_update', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_env_raw_read',
|
||||
{
|
||||
description: 'Read raw content of an .env file',
|
||||
inputSchema: KitEnvRawReadInputSchema,
|
||||
outputSchema: KitEnvRawReadOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitEnvRawReadInputSchema.parse(input);
|
||||
const result = await service.rawRead(parsed.file);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_env_raw_read', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_env_raw_write',
|
||||
{
|
||||
description: 'Write raw content to an .env file',
|
||||
inputSchema: KitEnvRawWriteInputSchema,
|
||||
outputSchema: KitEnvRawWriteOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitEnvRawWriteInputSchema.parse(input);
|
||||
const result = await service.rawWrite(parsed.file, parsed.content);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_env_raw_write', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildErrorResponse(tool: string, error: unknown) {
|
||||
const message = `${tool} failed: ${toErrorMessage(error)}`;
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
content: buildTextContent(message),
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
function buildTextContent(text: string): TextContent[] {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
export {
|
||||
createKitEnvService,
|
||||
createKitEnvDeps,
|
||||
envVariables,
|
||||
findWorkspaceRoot,
|
||||
scanMonorepoEnv,
|
||||
processEnvDefinitions,
|
||||
getEnvState,
|
||||
getVariable,
|
||||
} from './public-api';
|
||||
export type { KitEnvDeps };
|
||||
export type { EnvVariableModel } from './model';
|
||||
export type {
|
||||
EnvMode,
|
||||
AppEnvState,
|
||||
EnvFileInfo,
|
||||
EnvVariableState,
|
||||
} from './types';
|
||||
320
packages/mcp-server/src/tools/env/kit-env.service.ts
vendored
Normal file
320
packages/mcp-server/src/tools/env/kit-env.service.ts
vendored
Normal file
@@ -0,0 +1,320 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { envVariables } from './model';
|
||||
import { getEnvState } from './scanner';
|
||||
import type { EnvMode, ScanFs } from './types';
|
||||
|
||||
export interface KitEnvDeps {
|
||||
rootPath: string;
|
||||
readFile(filePath: string): Promise<string>;
|
||||
writeFile(filePath: string, content: string): Promise<void>;
|
||||
fileExists(filePath: string): Promise<boolean>;
|
||||
readdir?(dirPath: string): Promise<string[]>;
|
||||
stat?(path: string): Promise<{ isDirectory(): boolean }>;
|
||||
}
|
||||
|
||||
export function createKitEnvService(deps: KitEnvDeps) {
|
||||
return new KitEnvService(deps);
|
||||
}
|
||||
|
||||
export class KitEnvService {
|
||||
constructor(private readonly deps: KitEnvDeps) {}
|
||||
|
||||
async getSchema() {
|
||||
const groups = new Map<
|
||||
string,
|
||||
Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
type: 'string' | 'url' | 'email' | 'number' | 'boolean' | 'enum';
|
||||
sensitive: boolean;
|
||||
values?: string[];
|
||||
hint?: string;
|
||||
dependencies?: Array<{ variable: string; condition: string }>;
|
||||
}>
|
||||
>();
|
||||
|
||||
for (const variable of envVariables) {
|
||||
const category = variable.category;
|
||||
|
||||
if (!groups.has(category)) {
|
||||
groups.set(category, []);
|
||||
}
|
||||
|
||||
groups.get(category)!.push({
|
||||
key: variable.name,
|
||||
label: variable.displayName,
|
||||
description: variable.description,
|
||||
required: variable.required ?? false,
|
||||
type: mapType(variable.type),
|
||||
sensitive: variable.secret ?? false,
|
||||
values: variable.values?.filter(
|
||||
(v): v is string => typeof v === 'string',
|
||||
),
|
||||
hint: variable.hint,
|
||||
dependencies: variable.contextualValidation?.dependencies.map(
|
||||
(dep) => ({
|
||||
variable: dep.variable,
|
||||
condition: dep.message,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
groups: Array.from(groups.entries()).map(([name, variables]) => ({
|
||||
name,
|
||||
description: `${name} configuration`,
|
||||
variables,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async read(mode: EnvMode) {
|
||||
const scanFs = this.getScanFs();
|
||||
const states = await getEnvState({
|
||||
mode,
|
||||
apps: ['web'],
|
||||
rootDir: this.deps.rootPath,
|
||||
fs: scanFs,
|
||||
});
|
||||
|
||||
const webState = states.find((state) => state.appName === 'web');
|
||||
|
||||
if (!webState) {
|
||||
return {
|
||||
mode,
|
||||
variables: {},
|
||||
};
|
||||
}
|
||||
|
||||
const allVariables = Object.values(webState.variables).reduce(
|
||||
(acc, variable) => {
|
||||
acc[variable.key] = variable.effectiveValue;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const variables = Object.fromEntries(
|
||||
Object.entries(webState.variables).map(([key, variable]) => {
|
||||
const model = envVariables.find((item) => item.name === key);
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
key,
|
||||
value: variable.effectiveValue,
|
||||
source: variable.effectiveSource,
|
||||
isOverridden: variable.isOverridden,
|
||||
overrideChain:
|
||||
variable.definitions.length > 1
|
||||
? variable.definitions.map((definition) => ({
|
||||
source: definition.source,
|
||||
value: definition.value,
|
||||
}))
|
||||
: undefined,
|
||||
validation: {
|
||||
valid: variable.validation.success,
|
||||
errors: variable.validation.error.issues,
|
||||
},
|
||||
dependencies: model?.contextualValidation?.dependencies.map(
|
||||
(dep) => {
|
||||
const dependencyValue = allVariables[dep.variable] ?? '';
|
||||
const satisfied = dep.condition(dependencyValue, allVariables);
|
||||
|
||||
return {
|
||||
variable: dep.variable,
|
||||
condition: dep.message,
|
||||
satisfied,
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
mode,
|
||||
variables,
|
||||
};
|
||||
}
|
||||
|
||||
async update(input: {
|
||||
key?: string;
|
||||
value?: string;
|
||||
file?: string;
|
||||
mode?: EnvMode;
|
||||
}) {
|
||||
if (!input.key || typeof input.value !== 'string') {
|
||||
throw new Error('Both key and value are required for kit_env_update');
|
||||
}
|
||||
|
||||
const fileName =
|
||||
input.file ??
|
||||
this.resolveDefaultFile(input.key, input.mode ?? 'development');
|
||||
const targetPath = this.resolveWebFile(fileName);
|
||||
|
||||
let content = '';
|
||||
|
||||
if (await this.deps.fileExists(targetPath)) {
|
||||
content = await this.deps.readFile(targetPath);
|
||||
}
|
||||
|
||||
const lines = content.length > 0 ? content.split('\n') : [];
|
||||
|
||||
let replaced = false;
|
||||
const updatedLines = lines.map((line) => {
|
||||
if (line.startsWith(`${input.key}=`)) {
|
||||
replaced = true;
|
||||
return `${input.key}=${input.value}`;
|
||||
}
|
||||
|
||||
return line;
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
if (
|
||||
updatedLines.length > 0 &&
|
||||
updatedLines[updatedLines.length - 1] !== ''
|
||||
) {
|
||||
updatedLines.push('');
|
||||
}
|
||||
|
||||
updatedLines.push(`${input.key}=${input.value}`);
|
||||
}
|
||||
|
||||
await this.deps.writeFile(targetPath, updatedLines.join('\n'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Updated ${input.key} in ${fileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
async rawRead(file: string) {
|
||||
const targetPath = this.resolveWebFile(file);
|
||||
|
||||
if (!(await this.deps.fileExists(targetPath))) {
|
||||
return {
|
||||
content: '',
|
||||
exists: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: await this.deps.readFile(targetPath),
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
|
||||
async rawWrite(file: string, content: string) {
|
||||
const targetPath = this.resolveWebFile(file);
|
||||
await this.deps.writeFile(targetPath, content);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Saved ${file}`,
|
||||
};
|
||||
}
|
||||
|
||||
async getVariable(key: string, mode: EnvMode) {
|
||||
const result = await this.read(mode);
|
||||
return result.variables[key]?.value ?? '';
|
||||
}
|
||||
|
||||
async getAppState(mode: EnvMode) {
|
||||
const scanFs = this.getScanFs();
|
||||
const states = await getEnvState({
|
||||
mode,
|
||||
apps: ['web'],
|
||||
rootDir: this.deps.rootPath,
|
||||
fs: scanFs,
|
||||
});
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
private resolveDefaultFile(key: string, mode: EnvMode) {
|
||||
const model = envVariables.find((item) => item.name === key);
|
||||
const isSecret = model?.secret ?? true;
|
||||
|
||||
if (mode === 'production') {
|
||||
return isSecret ? '.env.production.local' : '.env.production';
|
||||
}
|
||||
|
||||
return isSecret ? '.env.local' : '.env.development';
|
||||
}
|
||||
|
||||
private resolveWebFile(fileName: string) {
|
||||
const webDir = path.resolve(this.deps.rootPath, 'apps', 'web');
|
||||
const resolved = path.resolve(webDir, fileName);
|
||||
|
||||
// Prevent path traversal outside the web app directory
|
||||
if (!resolved.startsWith(webDir + path.sep) && resolved !== webDir) {
|
||||
throw new Error(
|
||||
`Invalid file path: "${fileName}" resolves outside the web app directory`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private getScanFs(): ScanFs | undefined {
|
||||
if (!this.deps.readdir || !this.deps.stat) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
readFile: (filePath) => this.deps.readFile(filePath),
|
||||
readdir: (dirPath) => this.deps.readdir!(dirPath),
|
||||
stat: (path) => this.deps.stat!(path),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mapType(
|
||||
type?: string,
|
||||
): 'string' | 'url' | 'email' | 'number' | 'boolean' | 'enum' {
|
||||
if (
|
||||
type === 'url' ||
|
||||
type === 'email' ||
|
||||
type === 'number' ||
|
||||
type === 'boolean' ||
|
||||
type === 'enum'
|
||||
) {
|
||||
return type;
|
||||
}
|
||||
|
||||
return 'string';
|
||||
}
|
||||
|
||||
export function createKitEnvDeps(rootPath = process.cwd()): KitEnvDeps {
|
||||
return {
|
||||
rootPath,
|
||||
readFile(filePath: string) {
|
||||
return fs.readFile(filePath, 'utf8');
|
||||
},
|
||||
writeFile(filePath: string, content: string) {
|
||||
return fs.writeFile(filePath, content, 'utf8');
|
||||
},
|
||||
readdir(dirPath: string) {
|
||||
return fs.readdir(dirPath);
|
||||
},
|
||||
stat(path: string) {
|
||||
return fs.stat(path);
|
||||
},
|
||||
async fileExists(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
1430
packages/mcp-server/src/tools/env/model.ts
vendored
Normal file
1430
packages/mcp-server/src/tools/env/model.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/mcp-server/src/tools/env/public-api.ts
vendored
Normal file
9
packages/mcp-server/src/tools/env/public-api.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export { envVariables } from './model';
|
||||
export {
|
||||
findWorkspaceRoot,
|
||||
getEnvState,
|
||||
getVariable,
|
||||
processEnvDefinitions,
|
||||
scanMonorepoEnv,
|
||||
} from './scanner';
|
||||
export { createKitEnvDeps, createKitEnvService } from './kit-env.service';
|
||||
480
packages/mcp-server/src/tools/env/scanner.ts
vendored
Normal file
480
packages/mcp-server/src/tools/env/scanner.ts
vendored
Normal file
@@ -0,0 +1,480 @@
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'path';
|
||||
|
||||
import { envVariables } from './model';
|
||||
import {
|
||||
AppEnvState,
|
||||
EnvFileInfo,
|
||||
EnvMode,
|
||||
EnvVariableState,
|
||||
ScanFs,
|
||||
ScanOptions,
|
||||
} from './types';
|
||||
|
||||
// Define precedence order for each mode
|
||||
const ENV_FILE_PRECEDENCE: Record<EnvMode, string[]> = {
|
||||
development: [
|
||||
'.env',
|
||||
'.env.development',
|
||||
'.env.local',
|
||||
'.env.development.local',
|
||||
],
|
||||
production: [
|
||||
'.env',
|
||||
'.env.production',
|
||||
'.env.local',
|
||||
'.env.production.local',
|
||||
],
|
||||
};
|
||||
|
||||
function getSourcePrecedence(source: string, mode: EnvMode): number {
|
||||
return ENV_FILE_PRECEDENCE[mode].indexOf(source);
|
||||
}
|
||||
|
||||
export async function scanMonorepoEnv(
|
||||
options: ScanOptions,
|
||||
): Promise<EnvFileInfo[]> {
|
||||
const {
|
||||
rootDir = findWorkspaceRoot(process.cwd()),
|
||||
apps = ['web'],
|
||||
mode,
|
||||
} = options;
|
||||
|
||||
const defaultFs: ScanFs = {
|
||||
readFile: (filePath) => fs.readFile(filePath, 'utf-8'),
|
||||
readdir: (dirPath) => fs.readdir(dirPath),
|
||||
stat: (path) => fs.stat(path),
|
||||
};
|
||||
const fsApi = options.fs ?? defaultFs;
|
||||
|
||||
const envTypes = ENV_FILE_PRECEDENCE[mode];
|
||||
const appsDir = path.join(rootDir, 'apps');
|
||||
const results: EnvFileInfo[] = [];
|
||||
|
||||
try {
|
||||
const appDirs = await fsApi.readdir(appsDir);
|
||||
|
||||
for (const appName of appDirs) {
|
||||
if (apps.length > 0 && !apps.includes(appName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appDir = path.join(appsDir, appName);
|
||||
const stat = await fsApi.stat(appDir);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appInfo: EnvFileInfo = {
|
||||
appName,
|
||||
filePath: appDir,
|
||||
variables: [],
|
||||
};
|
||||
|
||||
for (const envType of envTypes) {
|
||||
const envPath = path.join(appDir, envType);
|
||||
|
||||
try {
|
||||
const content = await fsApi.readFile(envPath);
|
||||
const vars = parseEnvFile(content, envType);
|
||||
|
||||
appInfo.variables.push(...vars);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(`Error reading ${envPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push(appInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scanning monorepo:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseEnvFile(content: string, source: string) {
|
||||
const variables: Array<{ key: string; value: string; source: string }> = [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.trim().startsWith('#') || !line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match KEY=VALUE pattern, handling quotes
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
const [, key = '', rawValue] = match;
|
||||
let value = rawValue ?? '';
|
||||
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Handle escaped quotes within the value
|
||||
value = value
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, '\\');
|
||||
|
||||
variables.push({
|
||||
key: key.trim(),
|
||||
value: value.trim(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
export function processEnvDefinitions(
|
||||
envInfo: EnvFileInfo,
|
||||
mode: EnvMode,
|
||||
): AppEnvState {
|
||||
const variableMap: Record<string, EnvVariableState> = {};
|
||||
|
||||
// First pass: Collect all definitions
|
||||
for (const variable of envInfo.variables) {
|
||||
if (!variable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = envVariables.find((v) => variable.key === v.name);
|
||||
|
||||
if (!variableMap[variable.key]) {
|
||||
variableMap[variable.key] = {
|
||||
key: variable.key,
|
||||
isVisible: true,
|
||||
definitions: [],
|
||||
effectiveValue: variable.value,
|
||||
effectiveSource: variable.source,
|
||||
isOverridden: false,
|
||||
category: model ? model.category : 'Custom',
|
||||
validation: {
|
||||
success: true,
|
||||
error: {
|
||||
issues: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const varState = variableMap[variable.key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
varState.definitions.push({
|
||||
key: variable.key,
|
||||
value: variable.value,
|
||||
source: variable.source,
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: Determine effective values and override status
|
||||
for (const key in variableMap) {
|
||||
const varState = variableMap[key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort definitions by mode-specific precedence
|
||||
varState.definitions.sort(
|
||||
(a, b) =>
|
||||
getSourcePrecedence(a.source, mode) -
|
||||
getSourcePrecedence(b.source, mode),
|
||||
);
|
||||
|
||||
if (varState.definitions.length > 1) {
|
||||
const lastDef = varState.definitions[varState.definitions.length - 1];
|
||||
|
||||
if (!lastDef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const highestPrecedence = getSourcePrecedence(lastDef.source, mode);
|
||||
|
||||
varState.isOverridden = true;
|
||||
varState.effectiveValue = lastDef.value;
|
||||
varState.effectiveSource = lastDef.source;
|
||||
|
||||
// Check for conflicts at highest precedence
|
||||
const conflictingDefs = varState.definitions.filter(
|
||||
(def) => getSourcePrecedence(def.source, mode) === highestPrecedence,
|
||||
);
|
||||
|
||||
if (conflictingDefs.length > 1) {
|
||||
varState.effectiveSource = `${varState.effectiveSource} (conflict)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a lookup of all effective values once (used by validations below)
|
||||
const allVariables: Record<string, string> = {};
|
||||
|
||||
for (const key in variableMap) {
|
||||
const varState = variableMap[key];
|
||||
if (varState) {
|
||||
allVariables[varState.key] = varState.effectiveValue;
|
||||
}
|
||||
}
|
||||
|
||||
// after computing the effective values, we can check for errors
|
||||
for (const key in variableMap) {
|
||||
const model = envVariables.find((v) => key === v.name);
|
||||
const varState = variableMap[key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let validation: {
|
||||
success: boolean;
|
||||
error: {
|
||||
issues: string[];
|
||||
};
|
||||
} = { success: true, error: { issues: [] } };
|
||||
|
||||
if (model) {
|
||||
// First check if it's required but missing (use pre-computed allVariables)
|
||||
if (model.required && !varState.effectiveValue) {
|
||||
validation = {
|
||||
success: false,
|
||||
error: {
|
||||
issues: [
|
||||
`This variable is required but missing from your environment files`,
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (model.contextualValidation) {
|
||||
// Then check contextual validation
|
||||
const dependenciesMet = model.contextualValidation.dependencies.some(
|
||||
(dep) => {
|
||||
const dependencyValue = allVariables[dep.variable] ?? '';
|
||||
|
||||
return dep.condition(dependencyValue, allVariables);
|
||||
},
|
||||
);
|
||||
|
||||
if (dependenciesMet) {
|
||||
// Only check for missing value or run validation if dependencies are met
|
||||
if (!varState.effectiveValue) {
|
||||
const dependencyErrors = model.contextualValidation.dependencies
|
||||
.map((dep) => {
|
||||
const dependencyValue = allVariables[dep.variable] ?? '';
|
||||
|
||||
const shouldValidate = dep.condition(
|
||||
dependencyValue,
|
||||
allVariables,
|
||||
);
|
||||
|
||||
if (shouldValidate) {
|
||||
const { success } = model.contextualValidation!.validate({
|
||||
value: varState.effectiveValue,
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dep.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((message): message is string => message !== null);
|
||||
|
||||
validation = {
|
||||
success: dependencyErrors.length === 0,
|
||||
error: {
|
||||
issues: dependencyErrors
|
||||
.map((message) => message)
|
||||
.filter((message) => !!message),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// If we have a value and dependencies are met, run contextual validation
|
||||
const result = model.contextualValidation.validate({
|
||||
value: varState.effectiveValue,
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
validation = {
|
||||
success: false,
|
||||
error: {
|
||||
issues: result.error.issues
|
||||
.map((issue) => issue.message)
|
||||
.filter((message) => !!message),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (model.validate && varState.effectiveValue) {
|
||||
// Only run regular validation if:
|
||||
// 1. There's no contextual validation
|
||||
// 2. There's a value to validate
|
||||
const result = model.validate({
|
||||
value: varState.effectiveValue,
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
validation = {
|
||||
success: false,
|
||||
error: {
|
||||
issues: result.error.issues
|
||||
.map((issue) => issue.message)
|
||||
.filter((message) => !!message),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
varState.validation = validation;
|
||||
}
|
||||
|
||||
// Final pass: Validate missing variables that are marked as required
|
||||
// or as having contextual validation
|
||||
for (const model of envVariables) {
|
||||
// If the variable exists in appState, use that
|
||||
const existingVar = variableMap[model.name];
|
||||
|
||||
if (existingVar) {
|
||||
// If the variable is already in the map, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
if (model.contextualValidation) {
|
||||
// Check if any dependency condition is met for this missing variable
|
||||
const errors = model.contextualValidation.dependencies.flatMap((dep) => {
|
||||
const dependencyValue = allVariables[dep.variable] ?? '';
|
||||
const shouldValidate = dep.condition(dependencyValue, allVariables);
|
||||
|
||||
if (!shouldValidate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Validate with the missing variable's empty value
|
||||
const validation = model.contextualValidation!.validate({
|
||||
value: '',
|
||||
variables: allVariables,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
return [dep.message];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
if (errors.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
variableMap[model.name] = {
|
||||
key: model.name,
|
||||
effectiveValue: '',
|
||||
effectiveSource: 'MISSING',
|
||||
isVisible: true,
|
||||
category: model.category,
|
||||
isOverridden: false,
|
||||
definitions: [],
|
||||
validation: {
|
||||
success: false,
|
||||
error: {
|
||||
issues: errors,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (model.required) {
|
||||
// Required but no contextual validation — generic required error
|
||||
variableMap[model.name] = {
|
||||
key: model.name,
|
||||
effectiveValue: '',
|
||||
effectiveSource: 'MISSING',
|
||||
isVisible: true,
|
||||
category: model.category,
|
||||
isOverridden: false,
|
||||
definitions: [],
|
||||
validation: {
|
||||
success: false,
|
||||
error: {
|
||||
issues: [
|
||||
`This variable is required but missing from your environment files`,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appName: envInfo.appName,
|
||||
filePath: envInfo.filePath,
|
||||
mode,
|
||||
variables: variableMap,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnvState(
|
||||
options: ScanOptions,
|
||||
): Promise<AppEnvState[]> {
|
||||
const envInfos = await scanMonorepoEnv(options);
|
||||
return envInfos.map((info) => processEnvDefinitions(info, options.mode));
|
||||
}
|
||||
|
||||
export async function getVariable(key: string, mode: EnvMode) {
|
||||
// Get the processed environment state for all apps (you can limit to 'web' via options)
|
||||
const envStates = await getEnvState({ mode, apps: ['web'] });
|
||||
|
||||
// Find the state for the "web" app.
|
||||
const webState = envStates.find((state) => state.appName === 'web');
|
||||
|
||||
// Return the effectiveValue based on override status.
|
||||
return webState?.variables[key]?.effectiveValue ?? '';
|
||||
}
|
||||
|
||||
export function findWorkspaceRoot(startPath: string) {
|
||||
let current = startPath;
|
||||
|
||||
for (let depth = 0; depth < 6; depth++) {
|
||||
const maybeWorkspace = path.join(current, 'pnpm-workspace.yaml');
|
||||
|
||||
if (existsSync(maybeWorkspace)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = path.join(current, '..');
|
||||
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return startPath;
|
||||
}
|
||||
102
packages/mcp-server/src/tools/env/schema.ts
vendored
Normal file
102
packages/mcp-server/src/tools/env/schema.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export const KitEnvModeSchema = z.enum(['development', 'production']);
|
||||
|
||||
export const KitEnvSchemaInputSchema = z.object({});
|
||||
|
||||
export const KitEnvReadInputSchema = z.object({
|
||||
mode: KitEnvModeSchema.default('development'),
|
||||
});
|
||||
|
||||
export const KitEnvUpdateInputSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
value: z.string(),
|
||||
mode: KitEnvModeSchema.optional(),
|
||||
file: z.string().optional(),
|
||||
});
|
||||
|
||||
export const KitEnvRawReadInputSchema = z.object({
|
||||
file: z.string().min(1),
|
||||
});
|
||||
|
||||
export const KitEnvRawWriteInputSchema = z.object({
|
||||
file: z.string().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const KitEnvSchemaOutputSchema = z.object({
|
||||
groups: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
variables: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string(),
|
||||
required: z.boolean(),
|
||||
type: z.enum(['string', 'url', 'email', 'number', 'boolean', 'enum']),
|
||||
sensitive: z.boolean(),
|
||||
values: z.array(z.string()).optional(),
|
||||
hint: z.string().optional(),
|
||||
dependencies: z
|
||||
.array(
|
||||
z.object({
|
||||
variable: z.string(),
|
||||
condition: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const KitEnvReadOutputSchema = z.object({
|
||||
mode: KitEnvModeSchema,
|
||||
variables: z.record(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
source: z.string(),
|
||||
isOverridden: z.boolean(),
|
||||
overrideChain: z
|
||||
.array(
|
||||
z.object({
|
||||
source: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
validation: z.object({
|
||||
valid: z.boolean(),
|
||||
errors: z.array(z.string()),
|
||||
}),
|
||||
dependencies: z
|
||||
.array(
|
||||
z.object({
|
||||
variable: z.string(),
|
||||
condition: z.string(),
|
||||
satisfied: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const KitEnvUpdateOutputSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const KitEnvRawReadOutputSchema = z.object({
|
||||
content: z.string(),
|
||||
exists: z.boolean(),
|
||||
});
|
||||
|
||||
export const KitEnvRawWriteOutputSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
54
packages/mcp-server/src/tools/env/types.ts
vendored
Normal file
54
packages/mcp-server/src/tools/env/types.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
export type EnvMode = 'development' | 'production';
|
||||
|
||||
export type ScanFs = {
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readdir: (dirPath: string) => Promise<string[]>;
|
||||
stat: (path: string) => Promise<{ isDirectory(): boolean }>;
|
||||
};
|
||||
|
||||
export type ScanOptions = {
|
||||
apps?: string[];
|
||||
rootDir?: string;
|
||||
mode: EnvMode;
|
||||
fs?: ScanFs;
|
||||
};
|
||||
|
||||
export type EnvDefinition = {
|
||||
key: string;
|
||||
value: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type EnvVariableState = {
|
||||
key: string;
|
||||
category: string;
|
||||
definitions: EnvDefinition[];
|
||||
effectiveValue: string;
|
||||
isOverridden: boolean;
|
||||
effectiveSource: string;
|
||||
isVisible: boolean;
|
||||
validation: {
|
||||
success: boolean;
|
||||
error: {
|
||||
issues: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AppEnvState = {
|
||||
appName: string;
|
||||
filePath: string;
|
||||
mode: EnvMode;
|
||||
variables: Record<string, EnvVariableState>;
|
||||
};
|
||||
|
||||
export type EnvFileInfo = {
|
||||
appName: string;
|
||||
filePath: string;
|
||||
|
||||
variables: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
source: string;
|
||||
}>;
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type KitMailboxDeps,
|
||||
createKitMailboxService,
|
||||
} from '../kit-mailbox.service';
|
||||
|
||||
function createDeps(overrides: Partial<KitMailboxDeps> = {}): KitMailboxDeps {
|
||||
return {
|
||||
async executeCommand() {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
async isPortOpen() {
|
||||
return true;
|
||||
},
|
||||
async fetchJson() {
|
||||
return {};
|
||||
},
|
||||
async requestJson() {
|
||||
return {};
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KitMailboxService', () => {
|
||||
it('lists messages from Mailpit API', async () => {
|
||||
const service = createKitMailboxService(
|
||||
createDeps({
|
||||
async executeCommand() {
|
||||
return { stdout: 'mailpit\n', stderr: '', exitCode: 0 };
|
||||
},
|
||||
async fetchJson(url: string) {
|
||||
if (url.endsWith('/info')) {
|
||||
return { version: 'v1' };
|
||||
}
|
||||
|
||||
return {
|
||||
total: 1,
|
||||
unread: 1,
|
||||
count: 1,
|
||||
messages: [
|
||||
{
|
||||
ID: 'abc',
|
||||
MessageID: 'm-1',
|
||||
Subject: 'Welcome',
|
||||
From: [{ Name: 'Makerkit', Address: 'noreply@makerkit.dev' }],
|
||||
To: [{ Address: 'user@example.com' }],
|
||||
Created: '2025-01-01T00:00:00Z',
|
||||
Size: 123,
|
||||
Read: false,
|
||||
ReadAt: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.list({ start: 0, limit: 50 });
|
||||
|
||||
expect(result.mail_server.running).toBe(true);
|
||||
expect(result.mail_server.running_via_docker).toBe(true);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.messages[0]).toEqual({
|
||||
id: 'abc',
|
||||
message_id: 'm-1',
|
||||
subject: 'Welcome',
|
||||
from: ['Makerkit <noreply@makerkit.dev>'],
|
||||
to: ['user@example.com'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
size: 123,
|
||||
read: false,
|
||||
});
|
||||
expect(result.messages[0]?.readAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads single message details', async () => {
|
||||
const service = createKitMailboxService(
|
||||
createDeps({
|
||||
async executeCommand() {
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
async fetchJson(url: string) {
|
||||
if (url.endsWith('/info')) {
|
||||
return { version: 'v1' };
|
||||
}
|
||||
|
||||
if (url.includes('/message/')) {
|
||||
return {
|
||||
ID: 'abc',
|
||||
MessageID: 'm-1',
|
||||
Subject: 'Welcome',
|
||||
From: [{ Address: 'noreply@makerkit.dev' }],
|
||||
To: [{ Address: 'user@example.com' }],
|
||||
Cc: [{ Address: 'team@example.com' }],
|
||||
Bcc: [],
|
||||
Text: ['Hello user'],
|
||||
HTML: ['<p>Hello user</p>'],
|
||||
Headers: {
|
||||
Subject: ['Welcome'],
|
||||
},
|
||||
Read: true,
|
||||
ReadAt: '2025-01-01T00:05:00Z',
|
||||
Size: 456,
|
||||
Created: '2025-01-01T00:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.read({ id: 'abc' });
|
||||
|
||||
expect(result.id).toBe('abc');
|
||||
expect(result.subject).toBe('Welcome');
|
||||
expect(result.from).toEqual(['noreply@makerkit.dev']);
|
||||
expect(result.to).toEqual(['user@example.com']);
|
||||
expect(result.cc).toEqual(['team@example.com']);
|
||||
expect(result.text).toBe('Hello user');
|
||||
expect(result.html).toBe('<p>Hello user</p>');
|
||||
expect(result.read).toBe(true);
|
||||
expect(result.readAt).toBe('2025-01-01T00:05:00Z');
|
||||
expect(result.headers).toEqual({ Subject: ['Welcome'] });
|
||||
});
|
||||
|
||||
it('updates read status for a message', async () => {
|
||||
const service = createKitMailboxService(
|
||||
createDeps({
|
||||
async requestJson(url: string) {
|
||||
expect(url).toContain('/message/abc/read');
|
||||
|
||||
return {
|
||||
id: 'abc',
|
||||
read: true,
|
||||
readAt: '2025-01-01T00:10:00Z',
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.setReadStatus({ id: 'abc', read: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'abc',
|
||||
read: true,
|
||||
readAt: '2025-01-01T00:10:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if mailpit runs in docker but API port is not open', async () => {
|
||||
const service = createKitMailboxService(
|
||||
createDeps({
|
||||
async isPortOpen() {
|
||||
return false;
|
||||
},
|
||||
async executeCommand() {
|
||||
return { stdout: 'mailpit\n', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.list({ start: 0, limit: 50 })).rejects.toThrow(
|
||||
'Mailpit appears running in docker but API is unreachable at http://127.0.0.1:8025',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if mailpit is not running', async () => {
|
||||
const service = createKitMailboxService(
|
||||
createDeps({
|
||||
async isPortOpen() {
|
||||
return false;
|
||||
},
|
||||
async executeCommand() {
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.list({ start: 0, limit: 50 })).rejects.toThrow(
|
||||
'Mailpit is not running. Start local services with "pnpm compose:dev:up".',
|
||||
);
|
||||
});
|
||||
});
|
||||
194
packages/mcp-server/src/tools/mailbox/index.ts
Normal file
194
packages/mcp-server/src/tools/mailbox/index.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { Socket } from 'node:net';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type KitMailboxDeps,
|
||||
createKitMailboxService,
|
||||
} from './kit-mailbox.service';
|
||||
import {
|
||||
KitEmailsListInputSchema,
|
||||
KitEmailsListOutputSchema,
|
||||
KitEmailsReadInputSchema,
|
||||
KitEmailsReadOutputSchema,
|
||||
KitEmailsSetReadStatusInputSchema,
|
||||
KitEmailsSetReadStatusOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type TextContent = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function registerKitEmailsTools(server: McpServer) {
|
||||
const service = createKitMailboxService(createKitMailboxDeps());
|
||||
|
||||
server.registerTool(
|
||||
'kit_emails_list',
|
||||
{
|
||||
description:
|
||||
'List received emails from the local Mailpit inbox (runtime mailbox, not source templates)',
|
||||
inputSchema: KitEmailsListInputSchema,
|
||||
outputSchema: KitEmailsListOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitEmailsListInputSchema.parse(input);
|
||||
const result = await service.list(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_emails_list', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_emails_read',
|
||||
{
|
||||
description:
|
||||
'Read a received email from the local Mailpit inbox by message id (includes text/html/headers)',
|
||||
inputSchema: KitEmailsReadInputSchema,
|
||||
outputSchema: KitEmailsReadOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitEmailsReadInputSchema.parse(input);
|
||||
const result = await service.read(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_emails_read', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'kit_emails_set_read_status',
|
||||
{
|
||||
description:
|
||||
'Set read/unread status for a received email in the local Mailpit inbox',
|
||||
inputSchema: KitEmailsSetReadStatusInputSchema,
|
||||
outputSchema: KitEmailsSetReadStatusOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const parsed = KitEmailsSetReadStatusInputSchema.parse(input);
|
||||
const result = await service.setReadStatus(parsed);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: buildTextContent(JSON.stringify(result)),
|
||||
};
|
||||
} catch (error) {
|
||||
return buildErrorResponse('kit_emails_set_read_status', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createKitMailboxDeps(rootPath = process.cwd()): KitMailboxDeps {
|
||||
return {
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await execFileAsync(command, args, { cwd: rootPath });
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
async isPortOpen(port: number) {
|
||||
return checkPort(port);
|
||||
},
|
||||
async fetchJson(url: string) {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Mailpit API request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
async requestJson(url: string, init) {
|
||||
const response = await fetch(url, {
|
||||
method: init?.method ?? 'GET',
|
||||
headers: init?.headers,
|
||||
body: init?.body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Mailpit API request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function checkPort(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new Socket();
|
||||
|
||||
socket.setTimeout(200);
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
function buildErrorResponse(tool: string, error: unknown) {
|
||||
const message = `${tool} failed: ${toErrorMessage(error)}`;
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
content: buildTextContent(message),
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
function buildTextContent(text: string): TextContent[] {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
export { createKitMailboxService } from './kit-mailbox.service';
|
||||
export type { KitMailboxDeps } from './kit-mailbox.service';
|
||||
export type {
|
||||
KitEmailsListOutput,
|
||||
KitEmailsReadOutput,
|
||||
KitEmailsSetReadStatusOutput,
|
||||
} from './schema';
|
||||
261
packages/mcp-server/src/tools/mailbox/kit-mailbox.service.ts
Normal file
261
packages/mcp-server/src/tools/mailbox/kit-mailbox.service.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type {
|
||||
KitEmailsListInput,
|
||||
KitEmailsListOutput,
|
||||
KitEmailsReadInput,
|
||||
KitEmailsReadOutput,
|
||||
KitEmailsSetReadStatusInput,
|
||||
KitEmailsSetReadStatusOutput,
|
||||
} from './schema';
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export interface KitMailboxDeps {
|
||||
executeCommand(command: string, args: string[]): Promise<CommandResult>;
|
||||
isPortOpen(port: number): Promise<boolean>;
|
||||
fetchJson(url: string): Promise<unknown>;
|
||||
requestJson(
|
||||
url: string,
|
||||
init?: {
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface MailServerStatus {
|
||||
running: boolean;
|
||||
running_via_docker: boolean;
|
||||
api_base_url: string;
|
||||
}
|
||||
|
||||
const MAILPIT_HTTP_PORT = 54324;
|
||||
const MAILPIT_API_BASE_URL = 'http://127.0.0.1:54324/api/v1';
|
||||
|
||||
export function createKitMailboxService(deps: KitMailboxDeps) {
|
||||
return new KitMailboxService(deps);
|
||||
}
|
||||
|
||||
export class KitMailboxService {
|
||||
constructor(private readonly deps: KitMailboxDeps) {}
|
||||
|
||||
async list(input: KitEmailsListInput): Promise<KitEmailsListOutput> {
|
||||
const mailServer = await this.ensureMailServerReady();
|
||||
|
||||
const payload = asRecord(
|
||||
await this.deps.fetchJson(
|
||||
`${MAILPIT_API_BASE_URL}/messages?start=${input.start}&limit=${input.limit}`,
|
||||
),
|
||||
);
|
||||
|
||||
const messages = asArray(payload.messages ?? payload.Messages).map(
|
||||
(message) => this.toSummary(asRecord(message)),
|
||||
);
|
||||
|
||||
return {
|
||||
mail_server: mailServer,
|
||||
start: toNumber(payload.start ?? payload.Start) ?? input.start,
|
||||
limit: toNumber(payload.limit ?? payload.Limit) ?? input.limit,
|
||||
count: toNumber(payload.count ?? payload.Count) ?? messages.length,
|
||||
total: toNumber(payload.total ?? payload.Total) ?? messages.length,
|
||||
unread: toNumber(payload.unread ?? payload.Unread),
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
async read(input: KitEmailsReadInput): Promise<KitEmailsReadOutput> {
|
||||
const mailServer = await this.ensureMailServerReady();
|
||||
|
||||
const message = asRecord(
|
||||
await this.deps.fetchJson(
|
||||
`${MAILPIT_API_BASE_URL}/message/${encodeURIComponent(input.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
mail_server: mailServer,
|
||||
id: toString(message.ID ?? message.id) ?? input.id,
|
||||
message_id: toString(message.MessageID ?? message.messageId),
|
||||
subject: toString(message.Subject ?? message.subject),
|
||||
from: readAddressList(message.From ?? message.from),
|
||||
to: readAddressList(message.To ?? message.to),
|
||||
cc: readAddressList(message.Cc ?? message.cc),
|
||||
bcc: readAddressList(message.Bcc ?? message.bcc),
|
||||
created_at: toString(message.Created ?? message.created),
|
||||
size: toNumber(message.Size ?? message.size),
|
||||
read: toBoolean(message.Read ?? message.read) ?? false,
|
||||
readAt: toString(message.ReadAt ?? message.readAt),
|
||||
text: readBody(message.Text ?? message.text),
|
||||
html: readBody(message.HTML ?? message.Html ?? message.html),
|
||||
headers: readHeaders(message.Headers ?? message.headers),
|
||||
raw: message,
|
||||
};
|
||||
}
|
||||
|
||||
async setReadStatus(
|
||||
input: KitEmailsSetReadStatusInput,
|
||||
): Promise<KitEmailsSetReadStatusOutput> {
|
||||
await this.ensureMailServerReady();
|
||||
|
||||
const response = asRecord(
|
||||
await this.deps.requestJson(
|
||||
`${MAILPIT_API_BASE_URL}/message/${encodeURIComponent(input.id)}/read`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ read: input.read }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const read = toBoolean(response.Read ?? response.read) ?? input.read;
|
||||
const readAt = toString(response.ReadAt ?? response.readAt);
|
||||
|
||||
return {
|
||||
id: toString(response.ID ?? response.id) ?? input.id,
|
||||
read,
|
||||
...(readAt ? { readAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private toSummary(message: Record<string, unknown>) {
|
||||
const readAt = toString(message.ReadAt ?? message.readAt);
|
||||
|
||||
return {
|
||||
id: toString(message.ID ?? message.id) ?? '',
|
||||
message_id: toString(message.MessageID ?? message.messageId),
|
||||
subject: toString(message.Subject ?? message.subject),
|
||||
from: readAddressList(message.From ?? message.from),
|
||||
to: readAddressList(message.To ?? message.to),
|
||||
created_at: toString(message.Created ?? message.created),
|
||||
size: toNumber(message.Size ?? message.size),
|
||||
read: toBoolean(message.Read ?? message.read) ?? false,
|
||||
...(readAt ? { readAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureMailServerReady(): Promise<MailServerStatus> {
|
||||
const running = await this.deps.isPortOpen(MAILPIT_HTTP_PORT);
|
||||
const runningViaDocker = await this.isMailpitRunningViaDocker();
|
||||
|
||||
if (!running) {
|
||||
if (runningViaDocker) {
|
||||
throw new Error(
|
||||
'Mailpit appears running in docker but API is unreachable at http://127.0.0.1:8025',
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Mailpit is not running. Start local services with "pnpm compose:dev:up".',
|
||||
);
|
||||
}
|
||||
|
||||
await this.deps.fetchJson(`${MAILPIT_API_BASE_URL}/info`);
|
||||
|
||||
return {
|
||||
running,
|
||||
running_via_docker: runningViaDocker,
|
||||
api_base_url: MAILPIT_API_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
private async isMailpitRunningViaDocker() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('docker', [
|
||||
'compose',
|
||||
'-f',
|
||||
'docker-compose.dev.yml',
|
||||
'ps',
|
||||
'--status',
|
||||
'running',
|
||||
'--services',
|
||||
]);
|
||||
|
||||
const services = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return services.includes('mailpit');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readHeaders(input: unknown) {
|
||||
const headers = asRecord(input);
|
||||
const normalized: Record<string, string[]> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
normalized[key] = asArray(value)
|
||||
.map((item) => toString(item))
|
||||
.filter((item): item is string => item !== null);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readBody(input: unknown): string | null {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
const chunks = input
|
||||
.map((item) => toString(item))
|
||||
.filter((item): item is string => item !== null);
|
||||
|
||||
return chunks.length > 0 ? chunks.join('\n\n') : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readAddressList(input: unknown): string[] {
|
||||
return asArray(input)
|
||||
.map((entry) => {
|
||||
const item = asRecord(entry);
|
||||
const address =
|
||||
toString(item.Address ?? item.address ?? item.Email ?? item.email) ??
|
||||
'';
|
||||
const name = toString(item.Name ?? item.name);
|
||||
|
||||
if (!address) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return name ? `${name} <${address}>` : address;
|
||||
})
|
||||
.filter((value): value is string => value !== null);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function toString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
79
packages/mcp-server/src/tools/mailbox/schema.ts
Normal file
79
packages/mcp-server/src/tools/mailbox/schema.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export const KitEmailsListInputSchema = z.object({
|
||||
start: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
|
||||
const MailServerStatusSchema = z.object({
|
||||
running: z.boolean(),
|
||||
running_via_docker: z.boolean(),
|
||||
api_base_url: z.string(),
|
||||
});
|
||||
|
||||
const EmailSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
message_id: z.string().nullable(),
|
||||
subject: z.string().nullable(),
|
||||
from: z.array(z.string()),
|
||||
to: z.array(z.string()),
|
||||
created_at: z.string().nullable(),
|
||||
size: z.number().nullable(),
|
||||
read: z.boolean(),
|
||||
readAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const KitEmailsListOutputSchema = z.object({
|
||||
mail_server: MailServerStatusSchema,
|
||||
start: z.number(),
|
||||
limit: z.number(),
|
||||
count: z.number(),
|
||||
total: z.number(),
|
||||
unread: z.number().nullable(),
|
||||
messages: z.array(EmailSummarySchema),
|
||||
});
|
||||
|
||||
export const KitEmailsReadInputSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const KitEmailsReadOutputSchema = z.object({
|
||||
mail_server: MailServerStatusSchema,
|
||||
id: z.string(),
|
||||
message_id: z.string().nullable(),
|
||||
subject: z.string().nullable(),
|
||||
from: z.array(z.string()),
|
||||
to: z.array(z.string()),
|
||||
cc: z.array(z.string()),
|
||||
bcc: z.array(z.string()),
|
||||
created_at: z.string().nullable(),
|
||||
size: z.number().nullable(),
|
||||
read: z.boolean(),
|
||||
readAt: z.string().optional(),
|
||||
text: z.string().nullable(),
|
||||
html: z.string().nullable(),
|
||||
headers: z.record(z.array(z.string())),
|
||||
raw: z.unknown(),
|
||||
});
|
||||
|
||||
export const KitEmailsSetReadStatusInputSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
read: z.boolean(),
|
||||
});
|
||||
|
||||
export const KitEmailsSetReadStatusOutputSchema = z.object({
|
||||
id: z.string(),
|
||||
read: z.boolean(),
|
||||
readAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export type KitEmailsListInput = z.infer<typeof KitEmailsListInputSchema>;
|
||||
export type KitEmailsListOutput = z.infer<typeof KitEmailsListOutputSchema>;
|
||||
export type KitEmailsReadInput = z.infer<typeof KitEmailsReadInputSchema>;
|
||||
export type KitEmailsReadOutput = z.infer<typeof KitEmailsReadOutputSchema>;
|
||||
export type KitEmailsSetReadStatusInput = z.infer<
|
||||
typeof KitEmailsSetReadStatusInputSchema
|
||||
>;
|
||||
export type KitEmailsSetReadStatusOutput = z.infer<
|
||||
typeof KitEmailsSetReadStatusOutputSchema
|
||||
>;
|
||||
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export class MigrationsTool {
|
||||
static GetMigrations() {
|
||||
@@ -35,9 +35,12 @@ export function registerGetMigrationsTools(server: McpServer) {
|
||||
}
|
||||
|
||||
function createDiffMigrationTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'diff_migrations',
|
||||
{
|
||||
description:
|
||||
'Compare differences between the declarative schemas and the applied migrations in Supabase',
|
||||
},
|
||||
async () => {
|
||||
const result = MigrationsTool.Diff();
|
||||
const text = result.toString('utf8');
|
||||
@@ -55,14 +58,16 @@ function createDiffMigrationTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createCreateMigrationTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'create_migration',
|
||||
'Create a new Supabase Postgres migration file',
|
||||
{
|
||||
description: 'Create a new Supabase Postgres migration file',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const result = MigrationsTool.CreateMigration(state.name);
|
||||
const text = result.toString('utf8');
|
||||
@@ -80,14 +85,17 @@ function createCreateMigrationTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetMigrationContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_migration_content',
|
||||
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
|
||||
{
|
||||
description:
|
||||
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await MigrationsTool.getMigrationContent(state.path);
|
||||
|
||||
@@ -104,9 +112,12 @@ function createGetMigrationContentTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetMigrationsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_migrations',
|
||||
{
|
||||
description:
|
||||
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
|
||||
},
|
||||
async () => {
|
||||
const migrations = await MigrationsTool.GetMigrations();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
// Custom phase for organizing user stories
|
||||
interface CustomPhase {
|
||||
@@ -34,6 +34,56 @@ interface UserStory {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
interface RiskItem {
|
||||
id: string;
|
||||
description: string;
|
||||
mitigation: string;
|
||||
owner: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
interface CrossDependency {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
blocking: boolean;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
interface DecisionLogEntry {
|
||||
id: string;
|
||||
date: string;
|
||||
decision: string;
|
||||
rationale: string;
|
||||
owner?: string;
|
||||
status: 'proposed' | 'accepted' | 'superseded';
|
||||
}
|
||||
|
||||
interface AgentTaskPacket {
|
||||
id: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
doneCriteria: string[];
|
||||
testPlan: string[];
|
||||
likelyFiles: string[];
|
||||
linkedStoryIds: string[];
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
interface StoryTraceabilityMap {
|
||||
storyId: string;
|
||||
featureId: string;
|
||||
acceptanceCriteriaIds: string[];
|
||||
successMetricIds: string[];
|
||||
}
|
||||
|
||||
interface CreateStructuredPRDOptions {
|
||||
nonGoals?: string[];
|
||||
outOfScope?: string[];
|
||||
assumptions?: string[];
|
||||
openQuestions?: string[];
|
||||
}
|
||||
|
||||
// Structured PRD following ChatPRD format
|
||||
interface StructuredPRD {
|
||||
introduction: {
|
||||
@@ -54,8 +104,16 @@ interface StructuredPRD {
|
||||
successMetrics: string[];
|
||||
};
|
||||
|
||||
nonGoals: string[];
|
||||
outOfScope: string[];
|
||||
assumptions: string[];
|
||||
openQuestions: string[];
|
||||
risks: RiskItem[];
|
||||
dependencies: CrossDependency[];
|
||||
|
||||
userStories: UserStory[];
|
||||
customPhases?: CustomPhase[];
|
||||
storyTraceability: StoryTraceabilityMap[];
|
||||
|
||||
technicalRequirements: {
|
||||
constraints: string[];
|
||||
@@ -63,6 +121,13 @@ interface StructuredPRD {
|
||||
complianceRequirements: string[];
|
||||
};
|
||||
|
||||
technicalContracts: {
|
||||
apis: string[];
|
||||
dataModels: string[];
|
||||
permissions: string[];
|
||||
integrationBoundaries: string[];
|
||||
};
|
||||
|
||||
acceptanceCriteria: {
|
||||
global: string[];
|
||||
qualityStandards: string[];
|
||||
@@ -75,10 +140,30 @@ interface StructuredPRD {
|
||||
nonNegotiables: string[];
|
||||
};
|
||||
|
||||
rolloutPlan: {
|
||||
featureFlags: string[];
|
||||
migrationPlan: string[];
|
||||
rolloutPhases: string[];
|
||||
rollbackConditions: string[];
|
||||
};
|
||||
|
||||
measurementPlan: {
|
||||
events: string[];
|
||||
dashboards: string[];
|
||||
baselineMetrics: string[];
|
||||
targetMetrics: string[];
|
||||
guardrailMetrics: string[];
|
||||
};
|
||||
|
||||
decisionLog: DecisionLogEntry[];
|
||||
agentTaskPackets: AgentTaskPacket[];
|
||||
changeLog: string[];
|
||||
|
||||
metadata: {
|
||||
version: string;
|
||||
created: string;
|
||||
lastUpdated: string;
|
||||
lastValidatedAt: string;
|
||||
approver: string;
|
||||
};
|
||||
|
||||
@@ -118,6 +203,7 @@ export class PRDManager {
|
||||
solutionDescription: string,
|
||||
keyFeatures: string[],
|
||||
successMetrics: string[],
|
||||
options?: CreateStructuredPRDOptions,
|
||||
): Promise<string> {
|
||||
await this.ensurePRDsDirectory();
|
||||
|
||||
@@ -140,12 +226,25 @@ export class PRDManager {
|
||||
keyFeatures,
|
||||
successMetrics,
|
||||
},
|
||||
nonGoals: options?.nonGoals ?? [],
|
||||
outOfScope: options?.outOfScope ?? [],
|
||||
assumptions: options?.assumptions ?? [],
|
||||
openQuestions: options?.openQuestions ?? [],
|
||||
risks: [],
|
||||
dependencies: [],
|
||||
userStories: [],
|
||||
storyTraceability: [],
|
||||
technicalRequirements: {
|
||||
constraints: [],
|
||||
integrationNeeds: [],
|
||||
complianceRequirements: [],
|
||||
},
|
||||
technicalContracts: {
|
||||
apis: [],
|
||||
dataModels: [],
|
||||
permissions: [],
|
||||
integrationBoundaries: [],
|
||||
},
|
||||
acceptanceCriteria: {
|
||||
global: [],
|
||||
qualityStandards: [],
|
||||
@@ -155,10 +254,27 @@ export class PRDManager {
|
||||
resources: [],
|
||||
nonNegotiables: [],
|
||||
},
|
||||
rolloutPlan: {
|
||||
featureFlags: [],
|
||||
migrationPlan: [],
|
||||
rolloutPhases: [],
|
||||
rollbackConditions: [],
|
||||
},
|
||||
measurementPlan: {
|
||||
events: [],
|
||||
dashboards: [],
|
||||
baselineMetrics: [],
|
||||
targetMetrics: [],
|
||||
guardrailMetrics: [],
|
||||
},
|
||||
decisionLog: [],
|
||||
agentTaskPackets: [],
|
||||
changeLog: ['Initial PRD created'],
|
||||
metadata: {
|
||||
version: '1.0',
|
||||
version: '2.0',
|
||||
created: now,
|
||||
lastUpdated: now,
|
||||
lastValidatedAt: now,
|
||||
approver: '',
|
||||
},
|
||||
progress: {
|
||||
@@ -294,6 +410,28 @@ export class PRDManager {
|
||||
suggestions.push('Add global acceptance criteria for quality standards');
|
||||
}
|
||||
|
||||
if (prd.nonGoals.length === 0 || prd.outOfScope.length === 0) {
|
||||
suggestions.push(
|
||||
'Define both non-goals and out-of-scope items to reduce implementation drift',
|
||||
);
|
||||
}
|
||||
|
||||
if (prd.openQuestions.length > 0) {
|
||||
suggestions.push(
|
||||
`${prd.openQuestions.length} open questions remain unresolved`,
|
||||
);
|
||||
}
|
||||
|
||||
if (prd.measurementPlan.targetMetrics.length === 0) {
|
||||
suggestions.push(
|
||||
'Define target metrics in measurementPlan to validate delivery impact',
|
||||
);
|
||||
}
|
||||
|
||||
if (prd.rolloutPlan.rolloutPhases.length === 0) {
|
||||
suggestions.push('Add rollout phases and rollback conditions');
|
||||
}
|
||||
|
||||
const vagueStories = prd.userStories.filter(
|
||||
(s) => s.acceptanceCriteria.length < 2,
|
||||
);
|
||||
@@ -336,11 +474,24 @@ export class PRDManager {
|
||||
}
|
||||
}
|
||||
|
||||
static async deletePRD(filename: string): Promise<string> {
|
||||
const filePath = join(this.PRDS_DIR, filename);
|
||||
|
||||
try {
|
||||
await unlink(filePath);
|
||||
return `PRD deleted successfully: ${filename}`;
|
||||
} catch {
|
||||
throw new Error(`PRD file "${filename}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
static async getProjectStatus(filename: string): Promise<{
|
||||
progress: number;
|
||||
summary: string;
|
||||
nextSteps: string[];
|
||||
blockers: UserStory[];
|
||||
openQuestions: string[];
|
||||
highRisks: RiskItem[];
|
||||
}> {
|
||||
const prd = await this.loadPRD(filename);
|
||||
|
||||
@@ -357,13 +508,16 @@ export class PRDManager {
|
||||
...nextPending.map((s) => `Start: ${s.title}`),
|
||||
];
|
||||
|
||||
const summary = `${prd.progress.completed}/${prd.progress.total} stories completed (${prd.progress.overall}%). Total stories: ${prd.userStories.length}`;
|
||||
const highRisks = prd.risks.filter((risk) => risk.severity === 'high');
|
||||
const summary = `${prd.progress.completed}/${prd.progress.total} stories completed (${prd.progress.overall}%). Total stories: ${prd.userStories.length}. Open questions: ${prd.openQuestions.length}. High risks: ${highRisks.length}.`;
|
||||
|
||||
return {
|
||||
progress: prd.progress.overall,
|
||||
summary,
|
||||
nextSteps,
|
||||
blockers,
|
||||
openQuestions: prd.openQuestions,
|
||||
highRisks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -526,7 +680,7 @@ export class PRDManager {
|
||||
const filePath = join(this.PRDS_DIR, filename);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
return this.normalizePRD(JSON.parse(content));
|
||||
} catch {
|
||||
throw new Error(`PRD file "${filename}" not found`);
|
||||
}
|
||||
@@ -536,11 +690,101 @@ export class PRDManager {
|
||||
filename: string,
|
||||
prd: StructuredPRD,
|
||||
): Promise<void> {
|
||||
prd.metadata.lastUpdated = new Date().toISOString().split('T')[0];
|
||||
const now = new Date().toISOString().split('T')[0];
|
||||
prd.metadata.lastUpdated = now;
|
||||
prd.metadata.lastValidatedAt = prd.metadata.lastValidatedAt || now;
|
||||
if (prd.changeLog.length === 0) {
|
||||
prd.changeLog.push(`Updated on ${now}`);
|
||||
}
|
||||
|
||||
const filePath = join(this.PRDS_DIR, filename);
|
||||
await writeFile(filePath, JSON.stringify(prd, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
private static normalizePRD(input: unknown): StructuredPRD {
|
||||
const prd = input as Partial<StructuredPRD>;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
return {
|
||||
introduction: {
|
||||
title: prd.introduction?.title ?? 'Untitled PRD',
|
||||
overview: prd.introduction?.overview ?? '',
|
||||
lastUpdated: prd.introduction?.lastUpdated ?? today,
|
||||
},
|
||||
problemStatement: {
|
||||
problem: prd.problemStatement?.problem ?? '',
|
||||
marketOpportunity: prd.problemStatement?.marketOpportunity ?? '',
|
||||
targetUsers: prd.problemStatement?.targetUsers ?? [],
|
||||
},
|
||||
solutionOverview: {
|
||||
description: prd.solutionOverview?.description ?? '',
|
||||
keyFeatures: prd.solutionOverview?.keyFeatures ?? [],
|
||||
successMetrics: prd.solutionOverview?.successMetrics ?? [],
|
||||
},
|
||||
nonGoals: prd.nonGoals ?? [],
|
||||
outOfScope: prd.outOfScope ?? [],
|
||||
assumptions: prd.assumptions ?? [],
|
||||
openQuestions: prd.openQuestions ?? [],
|
||||
risks: prd.risks ?? [],
|
||||
dependencies: prd.dependencies ?? [],
|
||||
userStories: prd.userStories ?? [],
|
||||
customPhases: prd.customPhases ?? [],
|
||||
storyTraceability: prd.storyTraceability ?? [],
|
||||
technicalRequirements: {
|
||||
constraints: prd.technicalRequirements?.constraints ?? [],
|
||||
integrationNeeds: prd.technicalRequirements?.integrationNeeds ?? [],
|
||||
complianceRequirements:
|
||||
prd.technicalRequirements?.complianceRequirements ?? [],
|
||||
},
|
||||
technicalContracts: {
|
||||
apis: prd.technicalContracts?.apis ?? [],
|
||||
dataModels: prd.technicalContracts?.dataModels ?? [],
|
||||
permissions: prd.technicalContracts?.permissions ?? [],
|
||||
integrationBoundaries:
|
||||
prd.technicalContracts?.integrationBoundaries ?? [],
|
||||
},
|
||||
acceptanceCriteria: {
|
||||
global: prd.acceptanceCriteria?.global ?? [],
|
||||
qualityStandards: prd.acceptanceCriteria?.qualityStandards ?? [],
|
||||
},
|
||||
constraints: {
|
||||
timeline: prd.constraints?.timeline ?? '',
|
||||
budget: prd.constraints?.budget,
|
||||
resources: prd.constraints?.resources ?? [],
|
||||
nonNegotiables: prd.constraints?.nonNegotiables ?? [],
|
||||
},
|
||||
rolloutPlan: {
|
||||
featureFlags: prd.rolloutPlan?.featureFlags ?? [],
|
||||
migrationPlan: prd.rolloutPlan?.migrationPlan ?? [],
|
||||
rolloutPhases: prd.rolloutPlan?.rolloutPhases ?? [],
|
||||
rollbackConditions: prd.rolloutPlan?.rollbackConditions ?? [],
|
||||
},
|
||||
measurementPlan: {
|
||||
events: prd.measurementPlan?.events ?? [],
|
||||
dashboards: prd.measurementPlan?.dashboards ?? [],
|
||||
baselineMetrics: prd.measurementPlan?.baselineMetrics ?? [],
|
||||
targetMetrics: prd.measurementPlan?.targetMetrics ?? [],
|
||||
guardrailMetrics: prd.measurementPlan?.guardrailMetrics ?? [],
|
||||
},
|
||||
decisionLog: prd.decisionLog ?? [],
|
||||
agentTaskPackets: prd.agentTaskPackets ?? [],
|
||||
changeLog: prd.changeLog ?? [],
|
||||
metadata: {
|
||||
version: prd.metadata?.version ?? '2.0',
|
||||
created: prd.metadata?.created ?? today,
|
||||
lastUpdated: prd.metadata?.lastUpdated ?? today,
|
||||
lastValidatedAt: prd.metadata?.lastValidatedAt ?? today,
|
||||
approver: prd.metadata?.approver ?? '',
|
||||
},
|
||||
progress: {
|
||||
overall: prd.progress?.overall ?? 0,
|
||||
completed: prd.progress?.completed ?? 0,
|
||||
total: prd.progress?.total ?? 0,
|
||||
blocked: prd.progress?.blocked ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static extractTitleFromAction(action: string): string {
|
||||
const cleaned = action.trim().toLowerCase();
|
||||
const words = cleaned.split(' ').slice(0, 4);
|
||||
@@ -604,6 +848,58 @@ export class PRDManager {
|
||||
content += `- ${metric}\n`;
|
||||
});
|
||||
|
||||
content += `\n## Scope Guardrails\n\n`;
|
||||
content += `### Non-Goals\n`;
|
||||
if (prd.nonGoals.length > 0) {
|
||||
prd.nonGoals.forEach((item) => {
|
||||
content += `- ${item}\n`;
|
||||
});
|
||||
} else {
|
||||
content += `- None specified\n`;
|
||||
}
|
||||
|
||||
content += `\n### Out of Scope\n`;
|
||||
if (prd.outOfScope.length > 0) {
|
||||
prd.outOfScope.forEach((item) => {
|
||||
content += `- ${item}\n`;
|
||||
});
|
||||
} else {
|
||||
content += `- None specified\n`;
|
||||
}
|
||||
|
||||
content += `\n### Assumptions\n`;
|
||||
if (prd.assumptions.length > 0) {
|
||||
prd.assumptions.forEach((item) => {
|
||||
content += `- ${item}\n`;
|
||||
});
|
||||
} else {
|
||||
content += `- None specified\n`;
|
||||
}
|
||||
|
||||
content += `\n### Open Questions\n`;
|
||||
if (prd.openQuestions.length > 0) {
|
||||
prd.openQuestions.forEach((item) => {
|
||||
content += `- ${item}\n`;
|
||||
});
|
||||
} else {
|
||||
content += `- None\n`;
|
||||
}
|
||||
|
||||
if (prd.risks.length > 0) {
|
||||
content += `\n## Risks\n`;
|
||||
prd.risks.forEach((risk) => {
|
||||
content += `- [${risk.severity}] ${risk.description} | Mitigation: ${risk.mitigation} | Owner: ${risk.owner}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (prd.dependencies.length > 0) {
|
||||
content += `\n## Dependencies\n`;
|
||||
prd.dependencies.forEach((dependency) => {
|
||||
const mode = dependency.blocking ? 'blocking' : 'non-blocking';
|
||||
content += `- ${dependency.name} (${mode}) - ${dependency.description}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
content += `\n## User Stories\n\n`;
|
||||
|
||||
const priorities: UserStory['priority'][] = ['P0', 'P1', 'P2', 'P3'];
|
||||
@@ -637,6 +933,20 @@ export class PRDManager {
|
||||
content += `**Blocked:** ${prd.progress.blocked} stories need attention\n`;
|
||||
}
|
||||
|
||||
if (prd.rolloutPlan.rolloutPhases.length > 0) {
|
||||
content += `\n## Rollout Plan\n`;
|
||||
prd.rolloutPlan.rolloutPhases.forEach((phase) => {
|
||||
content += `- ${phase}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (prd.measurementPlan.targetMetrics.length > 0) {
|
||||
content += `\n## Measurement Plan\n`;
|
||||
prd.measurementPlan.targetMetrics.forEach((metric) => {
|
||||
content += `- ${metric}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
content += `\n---\n\n`;
|
||||
content += `*Approver: ${prd.metadata.approver || 'TBD'}*\n`;
|
||||
|
||||
@@ -661,6 +971,7 @@ export function registerPRDTools(server: McpServer) {
|
||||
createListPRDsTool(server);
|
||||
createGetPRDTool(server);
|
||||
createCreatePRDTool(server);
|
||||
createDeletePRDTool(server);
|
||||
createAddUserStoryTool(server);
|
||||
createUpdateStoryStatusTool(server);
|
||||
createExportMarkdownTool(server);
|
||||
@@ -670,9 +981,11 @@ export function registerPRDTools(server: McpServer) {
|
||||
}
|
||||
|
||||
function createListPRDsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'list_prds',
|
||||
'List all Product Requirements Documents',
|
||||
{
|
||||
description: 'List all Product Requirements Documents',
|
||||
},
|
||||
async () => {
|
||||
const prds = await PRDManager.listPRDs();
|
||||
|
||||
@@ -702,14 +1015,16 @@ function createListPRDsTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetPRDTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_prd',
|
||||
'Get the contents of a specific PRD file',
|
||||
{
|
||||
description: 'Get the contents of a specific PRD file',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await PRDManager.getPRDContent(state.filename);
|
||||
|
||||
@@ -726,10 +1041,12 @@ function createGetPRDTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createCreatePRDTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'create_prd',
|
||||
'Create a new structured PRD following ChatPRD best practices',
|
||||
{
|
||||
description:
|
||||
'Create a new structured PRD following ChatPRD best practices',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
title: z.string(),
|
||||
overview: z.string(),
|
||||
@@ -739,8 +1056,13 @@ function createCreatePRDTool(server: McpServer) {
|
||||
solutionDescription: z.string(),
|
||||
keyFeatures: z.array(z.string()),
|
||||
successMetrics: z.array(z.string()),
|
||||
nonGoals: z.array(z.string()).optional(),
|
||||
outOfScope: z.array(z.string()).optional(),
|
||||
assumptions: z.array(z.string()).optional(),
|
||||
openQuestions: z.array(z.string()).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const filename = await PRDManager.createStructuredPRD(
|
||||
state.title,
|
||||
@@ -751,6 +1073,12 @@ function createCreatePRDTool(server: McpServer) {
|
||||
state.solutionDescription,
|
||||
state.keyFeatures,
|
||||
state.successMetrics,
|
||||
{
|
||||
nonGoals: state.nonGoals,
|
||||
outOfScope: state.outOfScope,
|
||||
assumptions: state.assumptions,
|
||||
openQuestions: state.openQuestions,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -765,11 +1093,38 @@ function createCreatePRDTool(server: McpServer) {
|
||||
);
|
||||
}
|
||||
|
||||
function createAddUserStoryTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'add_user_story',
|
||||
'Add a new user story to an existing PRD',
|
||||
function createDeletePRDTool(server: McpServer) {
|
||||
return server.registerTool(
|
||||
'delete_prd',
|
||||
{
|
||||
description: 'Delete an existing PRD file',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const result = await PRDManager.deletePRD(state.filename);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createAddUserStoryTool(server: McpServer) {
|
||||
return server.registerTool(
|
||||
'add_user_story',
|
||||
{
|
||||
description: 'Add a new user story to an existing PRD',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
userType: z.string(),
|
||||
@@ -779,6 +1134,7 @@ function createAddUserStoryTool(server: McpServer) {
|
||||
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P2'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const result = await PRDManager.addUserStory(
|
||||
state.filename,
|
||||
@@ -802,10 +1158,11 @@ function createAddUserStoryTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createUpdateStoryStatusTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'update_story_status',
|
||||
'Update the status of a specific user story',
|
||||
{
|
||||
description: 'Update the status of a specific user story',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
storyId: z.string(),
|
||||
@@ -820,6 +1177,7 @@ function createUpdateStoryStatusTool(server: McpServer) {
|
||||
notes: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const result = await PRDManager.updateStoryStatus(
|
||||
state.filename,
|
||||
@@ -841,14 +1199,16 @@ function createUpdateStoryStatusTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createExportMarkdownTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'export_prd_markdown',
|
||||
'Export PRD as markdown for visualization and sharing',
|
||||
{
|
||||
description: 'Export PRD as markdown for visualization and sharing',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const markdownFile = await PRDManager.exportAsMarkdown(state.filename);
|
||||
|
||||
@@ -865,14 +1225,16 @@ function createExportMarkdownTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetImplementationPromptsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_implementation_prompts',
|
||||
'Generate Claude Code implementation prompts from PRD',
|
||||
{
|
||||
description: 'Generate Claude Code implementation prompts from PRD',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const prompts = await PRDManager.generateImplementationPrompts(
|
||||
state.filename,
|
||||
@@ -904,14 +1266,16 @@ function createGetImplementationPromptsTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetImprovementSuggestionsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_improvement_suggestions',
|
||||
'Get AI-powered suggestions to improve the PRD',
|
||||
{
|
||||
description: 'Get AI-powered suggestions to improve the PRD',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const suggestions = await PRDManager.getImprovementSuggestions(
|
||||
state.filename,
|
||||
@@ -943,14 +1307,16 @@ function createGetImprovementSuggestionsTool(server: McpServer) {
|
||||
}
|
||||
|
||||
function createGetProjectStatusTool(server: McpServer) {
|
||||
return server.tool(
|
||||
return server.registerTool(
|
||||
'get_project_status',
|
||||
'Get comprehensive status overview of the PRD project',
|
||||
{
|
||||
description: 'Get comprehensive status overview of the PRD project',
|
||||
inputSchema: {
|
||||
state: z.object({
|
||||
filename: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ state }) => {
|
||||
const status = await PRDManager.getProjectStatus(state.filename);
|
||||
|
||||
@@ -970,6 +1336,22 @@ function createGetProjectStatusTool(server: McpServer) {
|
||||
status.blockers.forEach((blocker) => {
|
||||
result += `- ${blocker.title}: ${blocker.notes || 'No details provided'}\n`;
|
||||
});
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
if (status.highRisks.length > 0) {
|
||||
result += `**High Risks:**\n`;
|
||||
status.highRisks.forEach((risk) => {
|
||||
result += `- ${risk.description} (Owner: ${risk.owner || 'Unassigned'})\n`;
|
||||
});
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
if (status.openQuestions.length > 0) {
|
||||
result += `**Open Questions:**\n`;
|
||||
status.openQuestions.slice(0, 5).forEach((question) => {
|
||||
result += `- ${question}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type KitPrerequisitesDeps,
|
||||
createKitPrerequisitesService,
|
||||
} from '../kit-prerequisites.service';
|
||||
|
||||
function createDeps(overrides: Partial<KitPrerequisitesDeps> = {}) {
|
||||
const base: KitPrerequisitesDeps = {
|
||||
async getVariantFamily() {
|
||||
return 'supabase';
|
||||
},
|
||||
async executeCommand(command: string, _args: string[]) {
|
||||
if (command === 'node')
|
||||
return { stdout: 'v22.5.0\n', stderr: '', exitCode: 0 };
|
||||
if (command === 'pnpm')
|
||||
return { stdout: '10.19.0\n', stderr: '', exitCode: 0 };
|
||||
if (command === 'git')
|
||||
return { stdout: 'git version 2.44.0\n', stderr: '', exitCode: 0 };
|
||||
if (command === 'docker')
|
||||
return {
|
||||
stdout: 'Docker version 26.1.0, build abc\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
if (command === 'supabase')
|
||||
return { stdout: '2.75.5\n', stderr: '', exitCode: 0 };
|
||||
if (command === 'stripe') throw new Error('not installed');
|
||||
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KitPrerequisitesService', () => {
|
||||
it('returns pass/warn statuses in a healthy supabase setup', async () => {
|
||||
const service = createKitPrerequisitesService(createDeps());
|
||||
const result = await service.check({});
|
||||
|
||||
expect(result.ready_to_develop).toBe(true);
|
||||
expect(result.overall).toBe('warn');
|
||||
|
||||
const node = result.prerequisites.find((item) => item.id === 'node');
|
||||
const supabase = result.prerequisites.find(
|
||||
(item) => item.id === 'supabase',
|
||||
);
|
||||
const stripe = result.prerequisites.find((item) => item.id === 'stripe');
|
||||
|
||||
expect(node?.status).toBe('pass');
|
||||
expect(supabase?.status).toBe('pass');
|
||||
expect(stripe?.status).toBe('warn');
|
||||
});
|
||||
|
||||
it('fails when required supabase cli is missing for supabase family', async () => {
|
||||
const service = createKitPrerequisitesService(
|
||||
createDeps({
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
if (command === 'supabase') {
|
||||
throw new Error('missing');
|
||||
}
|
||||
|
||||
return createDeps().executeCommand(command, args);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.check({});
|
||||
const supabase = result.prerequisites.find(
|
||||
(item) => item.id === 'supabase',
|
||||
);
|
||||
|
||||
expect(supabase?.required).toBe(true);
|
||||
expect(supabase?.status).toBe('fail');
|
||||
expect(result.overall).toBe('fail');
|
||||
expect(result.ready_to_develop).toBe(false);
|
||||
});
|
||||
|
||||
it('treats supabase cli as optional for orm family', async () => {
|
||||
const service = createKitPrerequisitesService(
|
||||
createDeps({
|
||||
async getVariantFamily() {
|
||||
return 'orm';
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
if (command === 'supabase') {
|
||||
throw new Error('missing');
|
||||
}
|
||||
|
||||
return createDeps().executeCommand(command, args);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.check({});
|
||||
const supabase = result.prerequisites.find(
|
||||
(item) => item.id === 'supabase',
|
||||
);
|
||||
|
||||
expect(supabase?.required).toBe(false);
|
||||
expect(supabase?.status).toBe('warn');
|
||||
expect(result.overall).toBe('warn');
|
||||
expect(result.ready_to_develop).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerKitPrerequisitesTool } from '../index';
|
||||
import { KitPrerequisitesOutputSchema } from '../schema';
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
handler: (input: unknown) => Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
describe('registerKitPrerequisitesTool', () => {
|
||||
it('registers kit_prerequisites and returns typed structured output', async () => {
|
||||
const tools: RegisteredTool[] = [];
|
||||
|
||||
const server = {
|
||||
registerTool(
|
||||
name: string,
|
||||
_config: Record<string, unknown>,
|
||||
handler: (input: unknown) => Promise<Record<string, unknown>>,
|
||||
) {
|
||||
tools.push({ name, handler });
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
registerKitPrerequisitesTool(server as never);
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]?.name).toBe('kit_prerequisites');
|
||||
|
||||
const result = await tools[0]!.handler({});
|
||||
const parsed = KitPrerequisitesOutputSchema.parse(result.structuredContent);
|
||||
|
||||
expect(parsed.prerequisites.length).toBeGreaterThan(0);
|
||||
expect(typeof parsed.ready_to_develop).toBe('boolean');
|
||||
});
|
||||
});
|
||||
191
packages/mcp-server/src/tools/prerequisites/index.ts
Normal file
191
packages/mcp-server/src/tools/prerequisites/index.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type KitPrerequisitesDeps,
|
||||
createKitPrerequisitesService,
|
||||
} from './kit-prerequisites.service';
|
||||
import {
|
||||
KitPrerequisitesInputSchema,
|
||||
KitPrerequisitesOutputSchema,
|
||||
} from './schema';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function registerKitPrerequisitesTool(server: McpServer) {
|
||||
return server.registerTool(
|
||||
'kit_prerequisites',
|
||||
{
|
||||
description: 'Check installed tools and versions for this kit variant',
|
||||
inputSchema: KitPrerequisitesInputSchema,
|
||||
outputSchema: KitPrerequisitesOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
const parsedInput = KitPrerequisitesInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const service = createKitPrerequisitesService(
|
||||
createKitPrerequisitesDeps(),
|
||||
);
|
||||
|
||||
const result = await service.check(parsedInput);
|
||||
|
||||
return {
|
||||
structuredContent: result,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `kit_prerequisites failed: ${toErrorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createKitPrerequisitesDeps(): KitPrerequisitesDeps {
|
||||
const rootPath = process.cwd();
|
||||
|
||||
return {
|
||||
async getVariantFamily() {
|
||||
const variant = await resolveVariant(rootPath);
|
||||
return variant.includes('supabase') ? 'supabase' : 'orm';
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await executeWithFallback(rootPath, command, args);
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeWithFallback(
|
||||
rootPath: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
) {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch (error) {
|
||||
// Local CLI tools are often installed in node_modules/.bin in this monorepo.
|
||||
if (isLocalCliCandidate(command)) {
|
||||
const localBinCandidates = [
|
||||
join(rootPath, 'node_modules', '.bin', command),
|
||||
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
|
||||
];
|
||||
|
||||
for (const localBin of localBinCandidates) {
|
||||
try {
|
||||
return await execFileAsync(localBin, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
// Try next local binary candidate.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await execFileAsync('pnpm', ['exec', command, ...args], {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
return execFileAsync(
|
||||
'pnpm',
|
||||
['--filter', 'web', 'exec', command, ...args],
|
||||
{
|
||||
cwd: rootPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === 'pnpm') {
|
||||
return execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalCliCandidate(command: string) {
|
||||
return command === 'supabase' || command === 'stripe';
|
||||
}
|
||||
|
||||
async function resolveVariant(rootPath: string) {
|
||||
const configPath = join(rootPath, '.makerkit', 'config.json');
|
||||
|
||||
try {
|
||||
await access(configPath);
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const variant =
|
||||
readString(config, 'variant') ??
|
||||
readString(config, 'template') ??
|
||||
readString(config, 'kitVariant');
|
||||
|
||||
if (variant) {
|
||||
return variant;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to heuristic.
|
||||
}
|
||||
|
||||
if (await pathExists(join(rootPath, 'apps', 'web', 'supabase'))) {
|
||||
return 'next-supabase';
|
||||
}
|
||||
|
||||
return 'next-drizzle';
|
||||
}
|
||||
|
||||
function readString(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
export {
|
||||
createKitPrerequisitesService,
|
||||
type KitPrerequisitesDeps,
|
||||
} from './kit-prerequisites.service';
|
||||
export type { KitPrerequisitesOutput } from './schema';
|
||||
@@ -0,0 +1,405 @@
|
||||
import type {
|
||||
KitPrerequisiteItem,
|
||||
KitPrerequisitesInput,
|
||||
KitPrerequisitesOutput,
|
||||
} from './schema';
|
||||
|
||||
type VariantFamily = 'supabase' | 'orm';
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
interface ToolVersion {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface KitPrerequisitesDeps {
|
||||
getVariantFamily(): Promise<VariantFamily>;
|
||||
executeCommand(command: string, args: string[]): Promise<CommandResult>;
|
||||
}
|
||||
|
||||
export function createKitPrerequisitesService(deps: KitPrerequisitesDeps) {
|
||||
return new KitPrerequisitesService(deps);
|
||||
}
|
||||
|
||||
export class KitPrerequisitesService {
|
||||
constructor(private readonly deps: KitPrerequisitesDeps) {}
|
||||
|
||||
async check(_input: KitPrerequisitesInput): Promise<KitPrerequisitesOutput> {
|
||||
const family = await this.deps.getVariantFamily();
|
||||
|
||||
const [node, pnpm, git, docker, supabaseCli, stripeCli] = await Promise.all(
|
||||
[
|
||||
this.getNodeVersion(),
|
||||
this.getPnpmVersion(),
|
||||
this.getGitVersion(),
|
||||
this.getDockerVersion(),
|
||||
this.getSupabaseVersion(),
|
||||
this.getStripeVersion(),
|
||||
],
|
||||
);
|
||||
|
||||
const prerequisites: KitPrerequisiteItem[] = [];
|
||||
|
||||
prerequisites.push(
|
||||
this.createRequiredItem({
|
||||
id: 'node',
|
||||
name: 'Node.js',
|
||||
minimumVersion: '20.10.0',
|
||||
installUrl: 'https://nodejs.org',
|
||||
version: node,
|
||||
}),
|
||||
);
|
||||
|
||||
prerequisites.push(
|
||||
this.createRequiredItem({
|
||||
id: 'pnpm',
|
||||
name: 'pnpm',
|
||||
minimumVersion: '10.0.0',
|
||||
installCommand: 'npm install -g pnpm',
|
||||
version: pnpm,
|
||||
}),
|
||||
);
|
||||
|
||||
prerequisites.push(
|
||||
this.createRequiredItem({
|
||||
id: 'git',
|
||||
name: 'Git',
|
||||
minimumVersion: '2.0.0',
|
||||
installUrl: 'https://git-scm.com/downloads',
|
||||
version: git,
|
||||
}),
|
||||
);
|
||||
|
||||
prerequisites.push(
|
||||
this.createRequiredItem({
|
||||
id: 'docker',
|
||||
name: 'Docker',
|
||||
minimumVersion: '20.10.0',
|
||||
installUrl: 'https://docker.com/products/docker-desktop',
|
||||
requiredFor:
|
||||
family === 'supabase' ? 'Local Supabase stack' : 'Local PostgreSQL',
|
||||
version: docker,
|
||||
}),
|
||||
);
|
||||
|
||||
prerequisites.push(
|
||||
this.createVariantConditionalItem({
|
||||
id: 'supabase',
|
||||
name: 'Supabase CLI',
|
||||
minimumVersion: '2.0.0',
|
||||
installCommand: 'npm install -g supabase',
|
||||
required: family === 'supabase',
|
||||
requiredFor: 'Supabase variants',
|
||||
version: supabaseCli,
|
||||
}),
|
||||
);
|
||||
|
||||
prerequisites.push(
|
||||
this.createVariantConditionalItem({
|
||||
id: 'stripe',
|
||||
name: 'Stripe CLI',
|
||||
minimumVersion: '1.0.0',
|
||||
installUrl: 'https://docs.stripe.com/stripe-cli',
|
||||
required: false,
|
||||
requiredFor: 'Payment webhook testing',
|
||||
version: stripeCli,
|
||||
}),
|
||||
);
|
||||
|
||||
const overall = this.computeOverall(prerequisites);
|
||||
|
||||
return {
|
||||
prerequisites,
|
||||
overall,
|
||||
ready_to_develop: overall !== 'fail',
|
||||
};
|
||||
}
|
||||
|
||||
private computeOverall(items: KitPrerequisiteItem[]) {
|
||||
if (items.some((item) => item.required && item.status === 'fail')) {
|
||||
return 'fail' as const;
|
||||
}
|
||||
|
||||
if (items.some((item) => item.status === 'warn')) {
|
||||
return 'warn' as const;
|
||||
}
|
||||
|
||||
return 'pass' as const;
|
||||
}
|
||||
|
||||
private createRequiredItem(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
minimumVersion: string;
|
||||
version: ToolVersion;
|
||||
installUrl?: string;
|
||||
installCommand?: string;
|
||||
requiredFor?: string;
|
||||
}): KitPrerequisiteItem {
|
||||
const status = this.getVersionStatus(params.version, params.minimumVersion);
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
required: true,
|
||||
required_for: params.requiredFor,
|
||||
installed: params.version.installed,
|
||||
version: params.version.version,
|
||||
minimum_version: params.minimumVersion,
|
||||
status,
|
||||
install_url: params.installUrl,
|
||||
install_command: params.installCommand,
|
||||
message: this.getMessage(params.version, params.minimumVersion, true),
|
||||
remedies: this.getRemedies(params, status, true),
|
||||
};
|
||||
}
|
||||
|
||||
private createVariantConditionalItem(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
minimumVersion: string;
|
||||
version: ToolVersion;
|
||||
required: boolean;
|
||||
requiredFor?: string;
|
||||
installUrl?: string;
|
||||
installCommand?: string;
|
||||
}): KitPrerequisiteItem {
|
||||
if (!params.required) {
|
||||
if (!params.version.installed) {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
required: false,
|
||||
required_for: params.requiredFor,
|
||||
installed: false,
|
||||
version: null,
|
||||
minimum_version: params.minimumVersion,
|
||||
status: 'warn',
|
||||
install_url: params.installUrl,
|
||||
install_command: params.installCommand,
|
||||
message: `${params.name} is optional but recommended for ${params.requiredFor ?? 'developer workflows'}.`,
|
||||
remedies: params.installCommand
|
||||
? [params.installCommand]
|
||||
: params.installUrl
|
||||
? [params.installUrl]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
const status = this.getVersionStatus(
|
||||
params.version,
|
||||
params.minimumVersion,
|
||||
);
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
required: false,
|
||||
required_for: params.requiredFor,
|
||||
installed: true,
|
||||
version: params.version.version,
|
||||
minimum_version: params.minimumVersion,
|
||||
status: status === 'fail' ? 'warn' : status,
|
||||
install_url: params.installUrl,
|
||||
install_command: params.installCommand,
|
||||
message: this.getMessage(params.version, params.minimumVersion, false),
|
||||
remedies: this.getRemedies(
|
||||
{
|
||||
...params,
|
||||
},
|
||||
status === 'fail' ? 'warn' : status,
|
||||
false,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const status = this.getVersionStatus(params.version, params.minimumVersion);
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
required: true,
|
||||
required_for: params.requiredFor,
|
||||
installed: params.version.installed,
|
||||
version: params.version.version,
|
||||
minimum_version: params.minimumVersion,
|
||||
status,
|
||||
install_url: params.installUrl,
|
||||
install_command: params.installCommand,
|
||||
message: this.getMessage(params.version, params.minimumVersion, true),
|
||||
remedies: this.getRemedies(params, status, true),
|
||||
};
|
||||
}
|
||||
|
||||
private getVersionStatus(version: ToolVersion, minimumVersion: string) {
|
||||
if (!version.installed || !version.version) {
|
||||
return 'fail' as const;
|
||||
}
|
||||
|
||||
const cmp = compareVersions(version.version, minimumVersion);
|
||||
|
||||
if (cmp < 0) {
|
||||
return 'fail' as const;
|
||||
}
|
||||
|
||||
return 'pass' as const;
|
||||
}
|
||||
|
||||
private getMessage(
|
||||
version: ToolVersion,
|
||||
minimumVersion: string,
|
||||
required: boolean,
|
||||
) {
|
||||
if (!version.installed) {
|
||||
return required
|
||||
? 'Required tool is not installed.'
|
||||
: 'Optional tool is not installed.';
|
||||
}
|
||||
|
||||
if (!version.version) {
|
||||
return 'Tool is installed but version could not be detected.';
|
||||
}
|
||||
|
||||
const cmp = compareVersions(version.version, minimumVersion);
|
||||
|
||||
if (cmp < 0) {
|
||||
return `Installed version ${version.version} is below minimum ${minimumVersion}.`;
|
||||
}
|
||||
|
||||
return `Installed version ${version.version} satisfies minimum ${minimumVersion}.`;
|
||||
}
|
||||
|
||||
private getRemedies(
|
||||
params: {
|
||||
installUrl?: string;
|
||||
installCommand?: string;
|
||||
minimumVersion: string;
|
||||
},
|
||||
status: 'pass' | 'warn' | 'fail',
|
||||
required: boolean,
|
||||
) {
|
||||
if (status === 'pass') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const remedies: string[] = [];
|
||||
|
||||
if (params.installCommand) {
|
||||
remedies.push(params.installCommand);
|
||||
}
|
||||
|
||||
if (params.installUrl) {
|
||||
remedies.push(params.installUrl);
|
||||
}
|
||||
|
||||
remedies.push(`Ensure version is >= ${params.minimumVersion}`);
|
||||
|
||||
if (!required && status === 'warn') {
|
||||
remedies.push(
|
||||
'Optional for development but useful for related workflows',
|
||||
);
|
||||
}
|
||||
|
||||
return remedies;
|
||||
}
|
||||
|
||||
private async getNodeVersion() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('node', ['--version']);
|
||||
return {
|
||||
installed: true,
|
||||
version: normalizeVersion(result.stdout),
|
||||
};
|
||||
} catch {
|
||||
return { installed: false, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async getPnpmVersion() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('pnpm', ['--version']);
|
||||
return {
|
||||
installed: true,
|
||||
version: normalizeVersion(result.stdout),
|
||||
};
|
||||
} catch {
|
||||
return { installed: false, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async getGitVersion() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('git', ['--version']);
|
||||
return {
|
||||
installed: true,
|
||||
version: normalizeVersion(result.stdout),
|
||||
};
|
||||
} catch {
|
||||
return { installed: false, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async getDockerVersion() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('docker', ['--version']);
|
||||
return {
|
||||
installed: true,
|
||||
version: normalizeVersion(result.stdout),
|
||||
};
|
||||
} catch {
|
||||
return { installed: false, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async getSupabaseVersion() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('supabase', ['--version']);
|
||||
return {
|
||||
installed: true,
|
||||
version: normalizeVersion(result.stdout),
|
||||
};
|
||||
} catch {
|
||||
return { installed: false, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async getStripeVersion() {
|
||||
try {
|
||||
const result = await this.deps.executeCommand('stripe', ['--version']);
|
||||
return {
|
||||
installed: true,
|
||||
version: normalizeVersion(result.stdout),
|
||||
};
|
||||
} catch {
|
||||
return { installed: false, version: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVersion(input: string) {
|
||||
const match = input.match(/\d+\.\d+\.\d+/);
|
||||
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string) {
|
||||
const left = a.split('.').map((part) => Number(part));
|
||||
const right = b.split('.').map((part) => Number(part));
|
||||
|
||||
const length = Math.max(left.length, right.length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = left[i] ?? 0;
|
||||
const r = right[i] ?? 0;
|
||||
|
||||
if (l > r) return 1;
|
||||
if (l < r) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
30
packages/mcp-server/src/tools/prerequisites/schema.ts
Normal file
30
packages/mcp-server/src/tools/prerequisites/schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export const KitPrerequisitesInputSchema = z.object({});
|
||||
|
||||
export const KitPrerequisiteItemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
required_for: z.string().optional(),
|
||||
installed: z.boolean(),
|
||||
version: z.string().nullable(),
|
||||
minimum_version: z.string().nullable(),
|
||||
status: z.enum(['pass', 'warn', 'fail']),
|
||||
install_url: z.string().optional(),
|
||||
install_command: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
remedies: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const KitPrerequisitesOutputSchema = z.object({
|
||||
prerequisites: z.array(KitPrerequisiteItemSchema),
|
||||
overall: z.enum(['pass', 'warn', 'fail']),
|
||||
ready_to_develop: z.boolean(),
|
||||
});
|
||||
|
||||
export type KitPrerequisitesInput = z.infer<typeof KitPrerequisitesInputSchema>;
|
||||
export type KitPrerequisiteItem = z.infer<typeof KitPrerequisiteItemSchema>;
|
||||
export type KitPrerequisitesOutput = z.infer<
|
||||
typeof KitPrerequisitesOutputSchema
|
||||
>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
interface PromptTemplate {
|
||||
name: string;
|
||||
@@ -280,10 +280,12 @@ export function registerPromptsSystem(server: McpServer) {
|
||||
{} as Record<string, z.ZodString | z.ZodOptional<z.ZodString>>,
|
||||
);
|
||||
|
||||
server.prompt(
|
||||
server.registerPrompt(
|
||||
promptTemplate.name,
|
||||
promptTemplate.description,
|
||||
{
|
||||
description: promptTemplate.description,
|
||||
argsSchema,
|
||||
},
|
||||
async (args: Record<string, string>) => {
|
||||
const renderedPrompt = PromptsManager.renderPrompt(
|
||||
promptTemplate.name,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type RunChecksDeps,
|
||||
createRunChecksService,
|
||||
} from '../run-checks.service';
|
||||
|
||||
function createDeps(
|
||||
overrides: Partial<RunChecksDeps> = {},
|
||||
scripts: Record<string, string> = {
|
||||
typecheck: 'tsc --noEmit',
|
||||
'lint:fix': 'eslint . --fix',
|
||||
'format:fix': 'prettier . --write',
|
||||
test: 'vitest run',
|
||||
},
|
||||
): RunChecksDeps {
|
||||
let nowValue = 0;
|
||||
|
||||
return {
|
||||
rootPath: '/repo',
|
||||
async readRootPackageJson() {
|
||||
return { scripts };
|
||||
},
|
||||
async executeCommand() {
|
||||
nowValue += 100;
|
||||
return {
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
now() {
|
||||
return nowValue;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RunChecksService', () => {
|
||||
it('runs default scripts and reports pass', async () => {
|
||||
const service = createRunChecksService(createDeps());
|
||||
const result = await service.run({});
|
||||
|
||||
expect(result.overall).toBe('pass');
|
||||
expect(result.scripts_requested).toEqual([
|
||||
'typecheck',
|
||||
'lint:fix',
|
||||
'format:fix',
|
||||
]);
|
||||
expect(result.summary.passed).toBe(3);
|
||||
});
|
||||
|
||||
it('includes tests when includeTests is true', async () => {
|
||||
const service = createRunChecksService(createDeps());
|
||||
const result = await service.run({ state: { includeTests: true } });
|
||||
|
||||
expect(result.scripts_requested).toContain('test');
|
||||
expect(result.summary.total).toBe(4);
|
||||
});
|
||||
|
||||
it('marks missing scripts as missing and fails overall', async () => {
|
||||
const service = createRunChecksService(
|
||||
createDeps(
|
||||
{},
|
||||
{
|
||||
typecheck: 'tsc --noEmit',
|
||||
},
|
||||
),
|
||||
);
|
||||
const result = await service.run({
|
||||
state: { scripts: ['typecheck', 'lint:fix'] },
|
||||
});
|
||||
|
||||
expect(result.overall).toBe('fail');
|
||||
expect(result.summary.missing).toBe(1);
|
||||
expect(
|
||||
result.checks.find((item) => item.script === 'lint:fix')?.status,
|
||||
).toBe('missing');
|
||||
});
|
||||
|
||||
it('stops subsequent checks when failFast is enabled', async () => {
|
||||
let calls = 0;
|
||||
const service = createRunChecksService(
|
||||
createDeps({
|
||||
async executeCommand(_command, args) {
|
||||
calls += 1;
|
||||
if (args[1] === 'typecheck') {
|
||||
return { stdout: '', stderr: 'boom', exitCode: 1 };
|
||||
}
|
||||
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.run({
|
||||
state: {
|
||||
scripts: ['typecheck', 'lint:fix', 'format:fix'],
|
||||
failFast: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(result.checks[0]?.status).toBe('fail');
|
||||
expect(result.checks[1]?.status).toBe('skipped');
|
||||
expect(result.checks[2]?.status).toBe('skipped');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user