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.
|
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:
|
Create in route's `_lib/server/` directory:
|
||||||
|
|
||||||
1. **Schema** (`_lib/schemas/feature.schema.ts`)
|
1. **Schema** (`_lib/schemas/feature.schema.ts`)
|
||||||
2. **Service** (`_lib/server/feature.service.ts`)
|
2. **Service** (`_lib/server/feature.service.ts`) — pure logic, dependencies injected, testable in isolation
|
||||||
3. **Actions** (`_lib/server/server-actions.ts`)
|
3. **Actions** (`_lib/server/server-actions.ts`) — thin adapter, no business logic
|
||||||
|
|
||||||
### Phase 3: UI Components
|
### Phase 3: UI Components
|
||||||
|
|
||||||
@@ -148,7 +150,9 @@ apps/web/app/home/[account]/projects/
|
|||||||
### Server Layer
|
### Server Layer
|
||||||
|
|
||||||
- [ ] Zod schema in `_lib/schemas/`
|
- [ ] 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`
|
- [ ] Server actions use `enhanceAction`
|
||||||
- [ ] Actions have `auth: true` and `schema` options
|
- [ ] Actions have `auth: true` and `schema` options
|
||||||
- [ ] Logging added for operations
|
- [ ] Logging added for operations
|
||||||
|
|||||||
@@ -29,22 +29,24 @@ export type CreateFeatureInput = z.infer<typeof CreateFeatureSchema>;
|
|||||||
|
|
||||||
### Step 2: Create Service Layer
|
### 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/`:
|
Create service in `_lib/server/`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// _lib/server/feature.service.ts
|
// _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';
|
import type { CreateFeatureInput } from '../schemas/feature.schema';
|
||||||
|
|
||||||
export function createFeatureService() {
|
export function createFeatureService(client: SupabaseClient) {
|
||||||
return new FeatureService();
|
return new FeatureService(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
class FeatureService {
|
class FeatureService {
|
||||||
async create(data: CreateFeatureInput) {
|
constructor(private readonly client: SupabaseClient) {}
|
||||||
const client = getSupabaseServerClient();
|
|
||||||
|
|
||||||
const { data: result, error } = await client
|
async create(data: CreateFeatureInput) {
|
||||||
|
const { data: result, error } = await this.client
|
||||||
.from('features')
|
.from('features')
|
||||||
.insert({
|
.insert({
|
||||||
name: data.name,
|
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`:
|
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 { enhanceAction } from '@kit/next/actions';
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
import { CreateFeatureSchema } from '../schemas/feature.schema';
|
import { CreateFeatureSchema } from '../schemas/feature.schema';
|
||||||
@@ -81,7 +88,8 @@ export const createFeatureAction = enhanceAction(
|
|||||||
|
|
||||||
logger.info(ctx, 'Creating feature');
|
logger.info(ctx, 'Creating feature');
|
||||||
|
|
||||||
const service = createFeatureService();
|
const client = getSupabaseServerClient();
|
||||||
|
const service = createFeatureService(client);
|
||||||
const result = await service.create(data);
|
const result = await service.create(data);
|
||||||
|
|
||||||
logger.info({ ...ctx, featureId: result.id }, 'Feature created');
|
logger.info({ ...ctx, featureId: result.id }, 'Feature created');
|
||||||
@@ -99,11 +107,13 @@ export const createFeatureAction = enhanceAction(
|
|||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
1. **Schema in separate file** - Reusable between client and server
|
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. **Service layer** - Business logic isolated from action
|
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. **Logging** - Always log before and after operations
|
3. **Schema in separate file** - Reusable between client and server
|
||||||
4. **Revalidation** - Use `revalidatePath` after mutations
|
4. **Logging** - Always log before and after operations
|
||||||
5. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
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
|
## 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');
|
||||||
|
});
|
||||||
|
```
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -47,4 +47,7 @@ yarn-error.log*
|
|||||||
.contentlayer/
|
.contentlayer/
|
||||||
|
|
||||||
# ts-node cache
|
# ts-node cache
|
||||||
node-compile-cache/
|
node-compile-cache/
|
||||||
|
|
||||||
|
# prds
|
||||||
|
.prds/
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"makerkit": {
|
"makerkit": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "node",
|
"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';
|
import { loadDatabaseToolsData } from './_lib/server/database-tools.loader';
|
||||||
|
|
||||||
interface DatabasePageProps {
|
interface DatabasePageProps {
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function DatabasePage({ searchParams }: DatabasePageProps) {
|
async function DatabasePage({ searchParams }: DatabasePageProps) {
|
||||||
const searchTerm = searchParams.search || '';
|
const searchTerm = (await searchParams).search || '';
|
||||||
|
|
||||||
// Load all database data server-side
|
// Load all database data server-side
|
||||||
const databaseData = await loadDatabaseToolsData();
|
const databaseData = await loadDatabaseToolsData();
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form';
|
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 { EnvModeSelector } from '@/components/env-mode-selector';
|
||||||
import { IFrame } from '@/components/iframe';
|
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 { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +17,8 @@ import {
|
|||||||
} from '@kit/ui/dialog';
|
} from '@kit/ui/dialog';
|
||||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
type EnvMode = 'development' | 'production';
|
||||||
|
|
||||||
type EmailPageProps = React.PropsWithChildren<{
|
type EmailPageProps = React.PropsWithChildren<{
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,25 +35,28 @@ export default async function EmailPage(props: EmailPageProps) {
|
|||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const mode = (await props.searchParams).mode ?? 'development';
|
const mode = (await props.searchParams).mode ?? 'development';
|
||||||
|
|
||||||
const template = await loadEmailTemplate(id);
|
const rootPath = findWorkspaceRoot(process.cwd());
|
||||||
const emailSettings = await getEmailSettings(mode);
|
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||||
|
|
||||||
const values: Record<string, string> = {
|
const [result, { templates }, emailSettings] = await Promise.all([
|
||||||
emails: 'Emails',
|
service.read({ id }),
|
||||||
'invite-email': 'Invite Email',
|
service.list(),
|
||||||
'account-delete-email': 'Account Delete Email',
|
getEmailSettings(mode),
|
||||||
'confirm-email': 'Confirm Email',
|
]);
|
||||||
'change-email-address-email': 'Change Email Address Email',
|
|
||||||
'reset-password-email': 'Reset Password Email',
|
const html = result.renderedHtml ?? result.source;
|
||||||
'magic-link-email': 'Magic Link Email',
|
|
||||||
'otp-email': 'OTP Email',
|
const values: Record<string, string> = { emails: 'Emails' };
|
||||||
};
|
|
||||||
|
for (const t of templates) {
|
||||||
|
values[t.id] = t.name;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page style={'custom'}>
|
<Page style={'custom'}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
displaySidebarTrigger={false}
|
displaySidebarTrigger={false}
|
||||||
title={values[id]}
|
title={values[id] ?? id}
|
||||||
description={<AppBreadcrumbs values={values} />}
|
description={<AppBreadcrumbs values={values} />}
|
||||||
>
|
>
|
||||||
<EnvModeSelector mode={mode} />
|
<EnvModeSelector mode={mode} />
|
||||||
@@ -77,7 +84,7 @@ export default async function EmailPage(props: EmailPageProps) {
|
|||||||
<IFrame className={'flex flex-1 flex-col'}>
|
<IFrame className={'flex flex-1 flex-col'}>
|
||||||
<div
|
<div
|
||||||
className={'flex flex-1 flex-col'}
|
className={'flex flex-1 flex-col'}
|
||||||
dangerouslySetInnerHTML={{ __html: template.html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
</IFrame>
|
</IFrame>
|
||||||
</PageBody>
|
</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';
|
'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: {
|
export async function sendEmailAction(params: {
|
||||||
template: string;
|
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({
|
return transporter.sendMail({
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createKitEmailsDeps,
|
||||||
|
createKitEmailsService,
|
||||||
|
} from '@kit/mcp-server/emails';
|
||||||
|
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||||
import {
|
import {
|
||||||
CardButton,
|
CardButton,
|
||||||
CardButtonHeader,
|
CardButtonHeader,
|
||||||
@@ -12,7 +17,16 @@ export const metadata = {
|
|||||||
title: 'Emails',
|
title: 'Emails',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
'supabase-auth': 'Supabase Auth Emails',
|
||||||
|
transactional: 'Transactional Emails',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function EmailsPage() {
|
export default async function EmailsPage() {
|
||||||
|
const rootPath = findWorkspaceRoot(process.cwd());
|
||||||
|
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||||
|
const { templates, categories } = await service.list();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page style={'custom'}>
|
<Page style={'custom'}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -22,73 +36,31 @@ export default async function EmailsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PageBody className={'gap-y-8'}>
|
<PageBody className={'gap-y-8'}>
|
||||||
<div className={'flex flex-col space-y-4'}>
|
{categories.map((category) => {
|
||||||
<Heading level={5}>Supabase Auth Emails</Heading>
|
const categoryTemplates = templates.filter(
|
||||||
|
(t) => t.category === category,
|
||||||
|
);
|
||||||
|
|
||||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
|
return (
|
||||||
<CardButton asChild>
|
<div key={category} className={'flex flex-col space-y-4'}>
|
||||||
<Link href={'/emails/confirm-email'}>
|
<Heading level={5}>
|
||||||
<CardButtonHeader>
|
{CATEGORY_LABELS[category] ?? category}
|
||||||
<CardButtonTitle>Confirm Email</CardButtonTitle>
|
</Heading>
|
||||||
</CardButtonHeader>
|
|
||||||
</Link>
|
|
||||||
</CardButton>
|
|
||||||
|
|
||||||
<CardButton asChild>
|
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
|
||||||
<Link href={'/emails/change-email-address-email'}>
|
{categoryTemplates.map((template) => (
|
||||||
<CardButtonHeader>
|
<CardButton key={template.id} asChild>
|
||||||
<CardButtonTitle>Change Email Address Email</CardButtonTitle>
|
<Link href={`/emails/${template.id}`}>
|
||||||
</CardButtonHeader>
|
<CardButtonHeader>
|
||||||
</Link>
|
<CardButtonTitle>{template.name}</CardButtonTitle>
|
||||||
</CardButton>
|
</CardButtonHeader>
|
||||||
|
</Link>
|
||||||
<CardButton asChild>
|
</CardButton>
|
||||||
<Link href={'/emails/reset-password-email'}>
|
))}
|
||||||
<CardButtonHeader>
|
</div>
|
||||||
<CardButtonTitle>Reset Password Email</CardButtonTitle>
|
</div>
|
||||||
</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>
|
|
||||||
</CardButtonHeader>
|
|
||||||
</Link>
|
|
||||||
</CardButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</Page>
|
</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 { ServiceCard } from '@/components/status-tile';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
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<{
|
export default async function DashboardPage() {
|
||||||
searchParams: Promise<{ mode?: EnvMode }>;
|
const [status, prerequisites] = await Promise.all([
|
||||||
}>;
|
loadDashboardKitStatus(),
|
||||||
|
loadDashboardKitPrerequisites(),
|
||||||
|
]);
|
||||||
|
|
||||||
export default async function DashboardPage(props: DashboardPageProps) {
|
const failedRequiredCount = prerequisites.prerequisites.filter(
|
||||||
const mode = (await props.searchParams).mode ?? 'development';
|
(item) => item.required && item.status === 'fail',
|
||||||
const connectivityService = createConnectivityService(mode);
|
).length;
|
||||||
|
|
||||||
const [supabaseStatus, supabaseAdminStatus, stripeStatus] = await Promise.all(
|
const warnCount = prerequisites.prerequisites.filter(
|
||||||
[
|
(item) => item.status === 'warn',
|
||||||
connectivityService.checkSupabaseConnectivity(),
|
).length;
|
||||||
connectivityService.checkSupabaseAdminConnectivity(),
|
|
||||||
connectivityService.checkStripeConnected(),
|
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 (
|
return (
|
||||||
@@ -27,17 +39,148 @@ export default async function DashboardPage(props: DashboardPageProps) {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
displaySidebarTrigger={false}
|
displaySidebarTrigger={false}
|
||||||
title={'Dev Tool'}
|
title={'Dev Tool'}
|
||||||
description={'Check the status of your Supabase and Stripe services'}
|
description={'Kit MCP status for this workspace'}
|
||||||
>
|
/>
|
||||||
<EnvModeSelector mode={mode} />
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<PageBody className={'space-y-8 py-2'}>
|
<PageBody className={'space-y-8 py-2'}>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<ServiceCard name={'Supabase API'} status={supabaseStatus} />
|
<ServiceCard
|
||||||
<ServiceCard name={'Supabase Admin'} status={supabaseAdminStatus} />
|
name={'Variant'}
|
||||||
<ServiceCard name={'Stripe API'} status={stripeStatus} />
|
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>
|
</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>
|
</PageBody>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Progress } from '@kit/ui/progress';
|
|||||||
import { Separator } from '@kit/ui/separator';
|
import { Separator } from '@kit/ui/separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||||
|
|
||||||
import { UserStoryDisplay } from '../../../_components/user-story-display';
|
import { UserStoryDisplay } from '../../_components/user-story-display';
|
||||||
import type { PRDData } from '../../../_lib/server/prd-page.loader';
|
import type { PRDData } from '../../_lib/server/prd-page.loader';
|
||||||
|
|
||||||
interface PRDDetailViewProps {
|
interface PRDDetailViewProps {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -2,7 +2,7 @@ import { Metadata } from 'next';
|
|||||||
|
|
||||||
import { notFound } from 'next/navigation';
|
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';
|
import { PRDDetailView } from './_components/prd-detail-view';
|
||||||
|
|
||||||
interface PRDPageProps {
|
interface PRDPageProps {
|
||||||
@@ -79,7 +79,7 @@ export function PRDsListInterface({ initialPrds }: PRDsListInterfaceProps) {
|
|||||||
{filteredPrds.map((prd) => (
|
{filteredPrds.map((prd) => (
|
||||||
<Link
|
<Link
|
||||||
key={prd.filename}
|
key={prd.filename}
|
||||||
href={`/mcp-server/prds/${prd.filename}`}
|
href={`/prds/${prd.filename}`}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
import { loadPRDs } from '../_lib/server/prd-loader';
|
|
||||||
import { PRDsListInterface } from './_components/prds-list-interface';
|
import { PRDsListInterface } from './_components/prds-list-interface';
|
||||||
|
import { loadPRDs } from './_lib/server/prd-loader';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'PRDs - MCP Server',
|
title: 'PRDs - MCP Server',
|
||||||
@@ -33,27 +33,7 @@ import {
|
|||||||
import { cn } from '@kit/ui/utils';
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
import { updateTranslationAction } from '../lib/server-actions';
|
import { updateTranslationAction } from '../lib/server-actions';
|
||||||
import type { TranslationData, Translations } from '../lib/translations-loader';
|
import type { 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>>;
|
|
||||||
|
|
||||||
export function TranslationsComparison({
|
export function TranslationsComparison({
|
||||||
translations,
|
translations,
|
||||||
@@ -74,35 +54,24 @@ export function TranslationsComparison({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const locales = Object.keys(translations);
|
const { base_locale, locales, namespaces } = translations;
|
||||||
const baseLocale = locales[0]!;
|
|
||||||
const namespaces = Object.keys(translations[baseLocale] || {});
|
|
||||||
|
|
||||||
const [selectedLocales, setSelectedLocales] = useState<Set<string>>(
|
const [selectedLocales, setSelectedLocales] = useState<Set<string>>(
|
||||||
new Set(locales),
|
new Set(locales),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Flatten translations for the selected namespace
|
|
||||||
const flattenedTranslations: FlattenedTranslations = {};
|
|
||||||
|
|
||||||
const [selectedNamespace, setSelectedNamespace] = useState(
|
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
|
// Get all unique keys across all translations
|
||||||
const allKeys = Array.from(
|
const allKeys = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
Object.values(flattenedTranslations).flatMap((data) => Object.keys(data)),
|
locales.flatMap((locale) =>
|
||||||
|
Object.keys(
|
||||||
|
translations.translations[locale]?.[selectedNamespace] ?? {},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
@@ -143,7 +112,7 @@ export function TranslationsComparison({
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [subject$]);
|
}, [subject$]);
|
||||||
|
|
||||||
if (locales.length === 0) {
|
if (locales.length === 0 || !base_locale) {
|
||||||
return <div>No translations found</div>;
|
return <div>No translations found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +197,16 @@ export function TranslationsComparison({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{visibleLocales.map((locale) => {
|
{visibleLocales.map((locale) => {
|
||||||
const translations = flattenedTranslations[locale] ?? {};
|
const translationsForLocale =
|
||||||
|
translations.translations[locale]?.[selectedNamespace] ??
|
||||||
|
{};
|
||||||
|
|
||||||
const baseTranslations =
|
const baseTranslations =
|
||||||
flattenedTranslations[baseLocale] ?? {};
|
translations.translations[base_locale]?.[
|
||||||
|
selectedNamespace
|
||||||
|
] ?? {};
|
||||||
|
|
||||||
const value = translations[key];
|
const value = translationsForLocale[key];
|
||||||
const baseValue = baseTranslations[key];
|
const baseValue = baseTranslations[key];
|
||||||
const isMissing = !value;
|
const isMissing = !value;
|
||||||
const isDifferent = value !== baseValue;
|
const isDifferent = value !== baseValue;
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:url';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||||
|
import {
|
||||||
|
createKitTranslationsDeps,
|
||||||
|
createKitTranslationsService,
|
||||||
|
} from '@kit/mcp-server/translations';
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
locale: z.string().min(1),
|
locale: z.string().min(1),
|
||||||
namespace: 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>) {
|
export async function updateTranslationAction(props: z.infer<typeof Schema>) {
|
||||||
// Validate the input
|
// Validate the input
|
||||||
const { locale, namespace, key, value } = Schema.parse(props);
|
const { locale, namespace, key, value } = Schema.parse(props);
|
||||||
|
const rootPath = findWorkspaceRoot(process.cwd());
|
||||||
|
|
||||||
const root = resolve(process.cwd(), '..');
|
const service = createKitTranslationsService(
|
||||||
const filePath = `${root}apps/web/public/locales/${locale}/${namespace}.json`;
|
createKitTranslationsDeps(rootPath),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read the current translations file
|
const result = await service.update({ locale, namespace, key, value });
|
||||||
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');
|
|
||||||
|
|
||||||
revalidatePath(`/translations`);
|
revalidatePath(`/translations`);
|
||||||
|
|
||||||
return { success: true };
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update translation:', error);
|
console.error('Failed to update translation:', error);
|
||||||
throw new Error('Failed to update translation');
|
throw new Error('Failed to update translation');
|
||||||
|
|||||||
@@ -1,50 +1,21 @@
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||||
import { join } from 'node:path';
|
import {
|
||||||
|
createKitTranslationsDeps,
|
||||||
export type TranslationData = {
|
createKitTranslationsService,
|
||||||
[key: string]: string | TranslationData;
|
} from '@kit/mcp-server/translations';
|
||||||
};
|
|
||||||
|
|
||||||
export type Translations = {
|
export type Translations = {
|
||||||
[locale: string]: {
|
base_locale: string;
|
||||||
[namespace: string]: TranslationData;
|
locales: string[];
|
||||||
};
|
namespaces: string[];
|
||||||
|
translations: Record<string, Record<string, Record<string, string>>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadTranslations() {
|
export async function loadTranslations(): Promise<Translations> {
|
||||||
const localesPath = join(process.cwd(), '../web/public/locales');
|
const rootPath = findWorkspaceRoot(process.cwd());
|
||||||
const localesDirents = readdirSync(localesPath, { withFileTypes: true });
|
const service = createKitTranslationsService(
|
||||||
|
createKitTranslationsDeps(rootPath),
|
||||||
|
);
|
||||||
|
|
||||||
const locales = localesDirents
|
return service.list();
|
||||||
.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'),
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
|
||||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
import { TranslationsComparison } from './components/translations-comparison';
|
import { TranslationsComparison } from './components/translations-comparison';
|
||||||
|
|||||||
@@ -1,471 +1,6 @@
|
|||||||
import 'server-only';
|
export {
|
||||||
|
getEnvState,
|
||||||
import fs from 'fs/promises';
|
getVariable,
|
||||||
import path from 'path';
|
processEnvDefinitions,
|
||||||
|
scanMonorepoEnv,
|
||||||
import { envVariables } from './env-variables-model';
|
} from '@kit/mcp-server/env';
|
||||||
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 ?? '';
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,83 +2,35 @@
|
|||||||
|
|
||||||
import { revalidatePath } from 'next/cache';
|
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 { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createKitEnvDeps,
|
||||||
|
createKitEnvService,
|
||||||
|
findWorkspaceRoot,
|
||||||
|
} from '@kit/mcp-server/env';
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
mode: z.enum(['development', 'production']),
|
mode: z.enum(['development', 'production']),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the environment variable in the specified file.
|
|
||||||
* @param props
|
|
||||||
*/
|
|
||||||
export async function updateEnvironmentVariableAction(
|
export async function updateEnvironmentVariableAction(
|
||||||
props: z.infer<typeof Schema>,
|
props: z.infer<typeof Schema>,
|
||||||
) {
|
) {
|
||||||
// Validate the input
|
|
||||||
const { name, mode, value } = Schema.parse(props);
|
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 rootPath = findWorkspaceRoot(process.cwd());
|
||||||
const source = (() => {
|
const service = createKitEnvService(createKitEnvDeps(rootPath));
|
||||||
const isSecret = model?.secret ?? true;
|
|
||||||
|
|
||||||
switch (mode) {
|
const result = await service.update({
|
||||||
case 'development':
|
key: name,
|
||||||
if (isSecret) {
|
value,
|
||||||
return '.env.local';
|
mode,
|
||||||
} else {
|
});
|
||||||
return '.env.development';
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'production':
|
revalidatePath('/variables');
|
||||||
if (isSecret) {
|
|
||||||
return '.env.production.local';
|
|
||||||
} else {
|
|
||||||
return '.env.production';
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
return result;
|
||||||
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}"`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { use } from 'react';
|
import { use } from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
processEnvDefinitions,
|
|
||||||
scanMonorepoEnv,
|
|
||||||
} from '@/app/variables/lib/env-scanner';
|
|
||||||
import { EnvMode } from '@/app/variables/lib/types';
|
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 { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager';
|
import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager';
|
||||||
@@ -21,7 +21,7 @@ export const metadata = {
|
|||||||
|
|
||||||
export default function VariablesPage({ searchParams }: VariablesPageProps) {
|
export default function VariablesPage({ searchParams }: VariablesPageProps) {
|
||||||
const { mode = 'development' } = use(searchParams);
|
const { mode = 'development' } = use(searchParams);
|
||||||
const apps = use(scanMonorepoEnv({ mode }));
|
const apps = use(loadEnvStates(mode));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page style={'custom'}>
|
<Page style={'custom'}>
|
||||||
@@ -36,19 +36,18 @@ export default function VariablesPage({ searchParams }: VariablesPageProps) {
|
|||||||
|
|
||||||
<PageBody className={'overflow-hidden'}>
|
<PageBody className={'overflow-hidden'}>
|
||||||
<div className={'flex h-full flex-1 flex-col space-y-4'}>
|
<div className={'flex h-full flex-1 flex-col space-y-4'}>
|
||||||
{apps.map((app) => {
|
{apps.map((app) => (
|
||||||
const appEnvState = processEnvDefinitions(app, mode);
|
<AppEnvironmentVariablesManager key={app.appName} state={app} />
|
||||||
|
))}
|
||||||
return (
|
|
||||||
<AppEnvironmentVariablesManager
|
|
||||||
key={app.appName}
|
|
||||||
state={appEnvState}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</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,
|
LanguagesIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
ServerIcon,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -55,20 +54,14 @@ const routes = [
|
|||||||
Icon: LanguagesIcon,
|
Icon: LanguagesIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'MCP Server',
|
label: 'Database',
|
||||||
Icon: ServerIcon,
|
path: '/database',
|
||||||
children: [
|
Icon: DatabaseIcon,
|
||||||
{
|
},
|
||||||
label: 'Database',
|
{
|
||||||
path: '/mcp-server/database',
|
label: 'PRD Manager',
|
||||||
Icon: DatabaseIcon,
|
path: '/prds',
|
||||||
},
|
Icon: FileTextIcon,
|
||||||
{
|
|
||||||
label: 'PRD Manager',
|
|
||||||
path: '/mcp-server/prds',
|
|
||||||
Icon: FileTextIcon,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
|
||||||
import { Card, CardContent } from '@kit/ui/card';
|
import { Card, CardContent } from '@kit/ui/card';
|
||||||
|
|
||||||
export const ServiceStatus = {
|
export const ServiceStatus = {
|
||||||
CHECKING: 'checking',
|
CHECKING: 'checking',
|
||||||
SUCCESS: 'success',
|
SUCCESS: 'success',
|
||||||
|
WARNING: 'warning',
|
||||||
|
INFO: 'info',
|
||||||
ERROR: 'error',
|
ERROR: 'error',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ type ServiceStatusType = (typeof ServiceStatus)[keyof typeof ServiceStatus];
|
|||||||
const StatusIcons = {
|
const StatusIcons = {
|
||||||
[ServiceStatus.CHECKING]: <AlertCircle className="h-6 w-6 text-yellow-500" />,
|
[ServiceStatus.CHECKING]: <AlertCircle className="h-6 w-6 text-yellow-500" />,
|
||||||
[ServiceStatus.SUCCESS]: <CheckCircle2 className="h-6 w-6 text-green-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" />,
|
[ServiceStatus.ERROR]: <XCircle className="h-6 w-6 text-red-500" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,7 +33,7 @@ interface ServiceCardProps {
|
|||||||
export const ServiceCard = ({ name, status }: ServiceCardProps) => {
|
export const ServiceCard = ({ name, status }: ServiceCardProps) => {
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-2xl">
|
<Card className="w-full max-w-2xl">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"author": "Makerkit",
|
"author": "Makerkit",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.1",
|
"@playwright/test": "^1.58.2",
|
||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"dotenv": "17.2.4",
|
"dotenv": "17.2.4",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ app/
|
|||||||
|
|
||||||
For specialized implementation:
|
For specialized implementation:
|
||||||
- `/feature-builder` - End-to-end feature implementation
|
- `/feature-builder` - End-to-end feature implementation
|
||||||
|
- `/service-builder` - Server side services
|
||||||
- `/server-action-builder` - Server actions
|
- `/server-action-builder` - Server actions
|
||||||
- `/forms-builder` - Forms with validation
|
- `/forms-builder` - Forms with validation
|
||||||
- `/navigation-config` - Adding routes and menu items
|
- `/navigation-config` - Adding routes and menu items
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export async function generateMetadata({
|
|||||||
url: data.entry.url,
|
url: data.entry.url,
|
||||||
images: image
|
images: image
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
url: image,
|
url: image,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- **ALWAYS** validate admin status before operations
|
- **ALWAYS** validate admin status before operations
|
||||||
- **NEVER** bypass authentication or authorization
|
- **NEVER** bypass authentication or authorization
|
||||||
- **ALWAYS** audit admin operations with logging
|
- **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
|
## Page Structure
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function ErrorPageContent({
|
|||||||
) : (
|
) : (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={backLink}>
|
<Link href={backLink}>
|
||||||
<ArrowLeft className={'h-4 w-4 mr-1'} />
|
<ArrowLeft className={'mr-1 h-4 w-4'} />
|
||||||
<Trans i18nKey={backLabel} />
|
<Trans i18nKey={backLabel} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -77,7 +77,7 @@ export function ErrorPageContent({
|
|||||||
|
|
||||||
<Button asChild variant={'ghost'}>
|
<Button asChild variant={'ghost'}>
|
||||||
<Link href={'/contact'}>
|
<Link href={'/contact'}>
|
||||||
<MessageCircleQuestion className={'h-4 w-4 mr-1'} />
|
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
|
||||||
<Trans i18nKey={contactLabel} />
|
<Trans i18nKey={contactLabel} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const config = {
|
|||||||
fullUrl: true,
|
fullUrl: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serverExternalPackages: ['pino', 'thread-stream'],
|
serverExternalPackages: [],
|
||||||
// needed for supporting dynamic imports for local content
|
// needed for supporting dynamic imports for local content
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
'/*': ['./content/**/*'],
|
'/*': ['./content/**/*'],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-supabase-saas-kit-turbo",
|
"name": "next-supabase-saas-kit-turbo",
|
||||||
"version": "2.23.14",
|
"version": "2.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"@turbo/gen": "^2.7.6",
|
"@turbo/gen": "^2.7.6",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"turbo": "2.7.6",
|
"turbo": "2.8.5",
|
||||||
"typescript": "^5.9.3"
|
"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",
|
"prettier": "@kit/prettier-config",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./registry": "./src/registry.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "catalog:"
|
"@react-email/components": "catalog:"
|
||||||
@@ -20,7 +21,10 @@
|
|||||||
"@kit/i18n": "workspace:*",
|
"@kit/i18n": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:",
|
||||||
|
"@types/react": "catalog:",
|
||||||
|
"react": "catalog:",
|
||||||
|
"react-dom": "catalog:"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"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,
|
supportedLngs: languages,
|
||||||
fallbackLng: languages[0],
|
fallbackLng: languages[0],
|
||||||
detection: undefined,
|
detection: undefined,
|
||||||
|
showSupportNotice: false,
|
||||||
lng,
|
lng,
|
||||||
preload: false as const,
|
preload: false as const,
|
||||||
lowerCaseLng: true as const,
|
lowerCaseLng: true as const,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function initializeI18nClient(
|
|||||||
.init(
|
.init(
|
||||||
{
|
{
|
||||||
...settings,
|
...settings,
|
||||||
|
showSupportNotice: false,
|
||||||
detection: {
|
detection: {
|
||||||
order: ['cookie', 'htmlTag', 'navigator'],
|
order: ['cookie', 'htmlTag', 'navigator'],
|
||||||
caches: ['cookie'],
|
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",
|
"name": "@kit/mcp-server",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"main": "./build/index.js",
|
"type": "module",
|
||||||
"module": true,
|
"main": "./build/index.cjs",
|
||||||
"bin": {
|
"bin": {
|
||||||
"makerkit-mcp-server": "./build/index.js"
|
"makerkit-mcp-server": "./build/index.cjs"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
"./database": "./src/tools/database.ts",
|
"./database": "./src/tools/database.ts",
|
||||||
@@ -13,28 +13,37 @@
|
|||||||
"./migrations": "./src/tools/migrations.ts",
|
"./migrations": "./src/tools/migrations.ts",
|
||||||
"./prd-manager": "./src/tools/prd-manager.ts",
|
"./prd-manager": "./src/tools/prd-manager.ts",
|
||||||
"./prompts": "./src/tools/prompts.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": {
|
"scripts": {
|
||||||
"clean": "rm -rf .turbo node_modules",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||||
"build": "tsc",
|
"build": "tsup",
|
||||||
"build:watch": "tsc --watch"
|
"build:watch": "tsup --watch",
|
||||||
|
"test:unit": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@kit/email-templates": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@modelcontextprotocol/sdk": "1.26.0",
|
"@modelcontextprotocol/sdk": "1.26.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"postgres": "3.4.8",
|
"postgres": "3.4.8",
|
||||||
|
"tsup": "catalog:",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"prettier": "@kit/prettier-config",
|
"prettier": "@kit/prettier-config"
|
||||||
"typesVersions": {
|
|
||||||
"*": {
|
|
||||||
"*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ import {
|
|||||||
registerDatabaseResources,
|
registerDatabaseResources,
|
||||||
registerDatabaseTools,
|
registerDatabaseTools,
|
||||||
} from './tools/database';
|
} 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 { registerGetMigrationsTools } from './tools/migrations';
|
||||||
import { registerPRDTools } from './tools/prd-manager';
|
import { registerPRDTools } from './tools/prd-manager';
|
||||||
|
import { registerKitPrerequisitesTool } from './tools/prerequisites/index';
|
||||||
import { registerPromptsSystem } from './tools/prompts';
|
import { registerPromptsSystem } from './tools/prompts';
|
||||||
|
import { registerRunChecksTool } from './tools/run-checks/index';
|
||||||
import { registerScriptsTools } from './tools/scripts';
|
import { registerScriptsTools } from './tools/scripts';
|
||||||
|
import { registerKitStatusTool } from './tools/status/index';
|
||||||
|
import { registerKitTranslationsTools } from './tools/translations/index';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Create server instance
|
// Create server instance
|
||||||
@@ -21,10 +31,20 @@ async function main() {
|
|||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
|
|
||||||
registerGetMigrationsTools(server);
|
registerGetMigrationsTools(server);
|
||||||
|
registerKitStatusTool(server);
|
||||||
|
registerKitPrerequisitesTool(server);
|
||||||
|
registerKitEnvTools(server);
|
||||||
|
registerKitDevTools(server);
|
||||||
|
registerKitDbTools(server);
|
||||||
|
registerKitEmailsTools(server);
|
||||||
|
registerKitEmailTemplatesTools(server);
|
||||||
|
registerKitTranslationsTools(server);
|
||||||
registerDatabaseTools(server);
|
registerDatabaseTools(server);
|
||||||
registerDatabaseResources(server);
|
registerDatabaseResources(server);
|
||||||
registerComponentsTools(server);
|
registerComponentsTools(server);
|
||||||
registerScriptsTools(server);
|
registerScriptsTools(server);
|
||||||
|
registerRunChecksTool(server);
|
||||||
|
registerDepsUpgradeAdvisorTool(server);
|
||||||
registerPRDTools(server);
|
registerPRDTools(server);
|
||||||
registerPromptsSystem(server);
|
registerPromptsSystem(server);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod/v3';
|
||||||
|
|
||||||
interface ComponentInfo {
|
interface ComponentInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -345,9 +345,12 @@ export function registerComponentsTools(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetComponentsTool(server: McpServer) {
|
function createGetComponentsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_components',
|
'get_components',
|
||||||
'Get all available UI components from the @kit/ui package with descriptions',
|
{
|
||||||
|
description:
|
||||||
|
'Get all available UI components from the @kit/ui package with descriptions',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const components = await ComponentsTool.getComponents();
|
const components = await ComponentsTool.getComponents();
|
||||||
|
|
||||||
@@ -371,13 +374,15 @@ function createGetComponentsTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetComponentContentTool(server: McpServer) {
|
function createGetComponentContentTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_component_content',
|
'get_component_content',
|
||||||
'Get the source code content of a specific UI component',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Get the source code content of a specific UI component',
|
||||||
componentName: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
componentName: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const content = await ComponentsTool.getComponentContent(
|
const content = await ComponentsTool.getComponentContent(
|
||||||
@@ -397,13 +402,16 @@ function createGetComponentContentTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createComponentsSearchTool(server: McpServer) {
|
function createComponentsSearchTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'components_search',
|
'components_search',
|
||||||
'Search UI components by keyword in name, description, or category',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
query: z.string(),
|
'Search UI components by keyword in name, description, or category',
|
||||||
}),
|
inputSchema: {
|
||||||
|
state: z.object({
|
||||||
|
query: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const components = await ComponentsTool.searchComponents(state.query);
|
const components = await ComponentsTool.searchComponents(state.query);
|
||||||
@@ -439,13 +447,16 @@ function createComponentsSearchTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetComponentPropsTool(server: McpServer) {
|
function createGetComponentPropsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_component_props',
|
'get_component_props',
|
||||||
'Extract component props, interfaces, and variants from a UI component',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
componentName: z.string(),
|
'Extract component props, interfaces, and variants from a UI component',
|
||||||
}),
|
inputSchema: {
|
||||||
|
state: z.object({
|
||||||
|
componentName: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const propsInfo = await ComponentsTool.getComponentProps(
|
const propsInfo = await ComponentsTool.getComponentProps(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|||||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod/v3';
|
||||||
|
|
||||||
const DATABASE_URL =
|
const DATABASE_URL =
|
||||||
process.env.DATABASE_URL ||
|
process.env.DATABASE_URL ||
|
||||||
@@ -1135,9 +1135,12 @@ export function registerDatabaseResources(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetSchemaFilesTool(server: McpServer) {
|
function createGetSchemaFilesTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_schema_files',
|
'get_schema_files',
|
||||||
'🔥 DATABASE SCHEMA FILES (SOURCE OF TRUTH - ALWAYS CURRENT) - Use these over migrations!',
|
{
|
||||||
|
description:
|
||||||
|
'🔥 DATABASE SCHEMA FILES (SOURCE OF TRUTH - ALWAYS CURRENT) - Use these over migrations!',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const schemaFiles = await DatabaseTool.getSchemaFiles();
|
const schemaFiles = await DatabaseTool.getSchemaFiles();
|
||||||
|
|
||||||
@@ -1168,9 +1171,12 @@ function createGetSchemaFilesTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetFunctionsTool(server: McpServer) {
|
function createGetFunctionsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_database_functions',
|
'get_database_functions',
|
||||||
'Get all database functions with descriptions and usage guidance',
|
{
|
||||||
|
description:
|
||||||
|
'Get all database functions with descriptions and usage guidance',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const functions = await DatabaseTool.getFunctions();
|
const functions = await DatabaseTool.getFunctions();
|
||||||
|
|
||||||
@@ -1202,13 +1208,16 @@ function createGetFunctionsTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetFunctionDetailsTool(server: McpServer) {
|
function createGetFunctionDetailsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_function_details',
|
'get_function_details',
|
||||||
'Get detailed information about a specific database function',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
functionName: z.string(),
|
'Get detailed information about a specific database function',
|
||||||
}),
|
inputSchema: {
|
||||||
|
state: z.object({
|
||||||
|
functionName: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const func = await DatabaseTool.getFunctionDetails(state.functionName);
|
const func = await DatabaseTool.getFunctionDetails(state.functionName);
|
||||||
@@ -1253,13 +1262,15 @@ Source File: ${func.sourceFile}`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createSearchFunctionsTool(server: McpServer) {
|
function createSearchFunctionsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'search_database_functions',
|
'search_database_functions',
|
||||||
'Search database functions by name, description, or purpose',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Search database functions by name, description, or purpose',
|
||||||
query: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
query: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const functions = await DatabaseTool.searchFunctions(state.query);
|
const functions = await DatabaseTool.searchFunctions(state.query);
|
||||||
@@ -1295,13 +1306,16 @@ function createSearchFunctionsTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetSchemaContentTool(server: McpServer) {
|
function createGetSchemaContentTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_schema_content',
|
'get_schema_content',
|
||||||
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
fileName: z.string(),
|
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
|
||||||
}),
|
inputSchema: {
|
||||||
|
state: z.object({
|
||||||
|
fileName: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const content = await DatabaseTool.getSchemaContent(state.fileName);
|
const content = await DatabaseTool.getSchemaContent(state.fileName);
|
||||||
@@ -1319,13 +1333,16 @@ function createGetSchemaContentTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetSchemasByTopicTool(server: McpServer) {
|
function createGetSchemasByTopicTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_schemas_by_topic',
|
'get_schemas_by_topic',
|
||||||
'🎯 Find schema files by topic (accounts, auth, billing, permissions, etc.) - Fastest way to find relevant schemas',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
topic: z.string(),
|
'🎯 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 }) => {
|
async ({ state }) => {
|
||||||
const schemas = await DatabaseTool.getSchemasByTopic(state.topic);
|
const schemas = await DatabaseTool.getSchemasByTopic(state.topic);
|
||||||
@@ -1368,13 +1385,16 @@ function createGetSchemasByTopicTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetSchemaBySectionTool(server: McpServer) {
|
function createGetSchemaBySectionTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_schema_by_section',
|
'get_schema_by_section',
|
||||||
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
section: z.string(),
|
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
|
||||||
}),
|
inputSchema: {
|
||||||
|
state: z.object({
|
||||||
|
section: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const schema = await DatabaseTool.getSchemaBySection(state.section);
|
const schema = await DatabaseTool.getSchemaBySection(state.section);
|
||||||
@@ -1414,9 +1434,12 @@ function createGetSchemaBySectionTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDatabaseSummaryTool(server: McpServer) {
|
function createDatabaseSummaryTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_database_summary',
|
'get_database_summary',
|
||||||
'📊 Get comprehensive database overview with tables, enums, and functions',
|
{
|
||||||
|
description:
|
||||||
|
'📊 Get comprehensive database overview with tables, enums, and functions',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const tables = await DatabaseTool.getAllProjectTables();
|
const tables = await DatabaseTool.getAllProjectTables();
|
||||||
const enums = await DatabaseTool.getAllEnums();
|
const enums = await DatabaseTool.getAllEnums();
|
||||||
@@ -1468,9 +1491,11 @@ function createDatabaseSummaryTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDatabaseTablesListTool(server: McpServer) {
|
function createDatabaseTablesListTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_database_tables',
|
'get_database_tables',
|
||||||
'📋 Get list of all project-defined database tables',
|
{
|
||||||
|
description: '📋 Get list of all project-defined database tables',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const tables = await DatabaseTool.getAllProjectTables();
|
const tables = await DatabaseTool.getAllProjectTables();
|
||||||
|
|
||||||
@@ -1487,14 +1512,17 @@ function createDatabaseTablesListTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetTableInfoTool(server: McpServer) {
|
function createGetTableInfoTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_table_info',
|
'get_table_info',
|
||||||
'🗂️ Get detailed table schema with columns, foreign keys, and indexes',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
schema: z.string().default('public'),
|
'🗂️ Get detailed table schema with columns, foreign keys, and indexes',
|
||||||
tableName: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
schema: z.string().default('public'),
|
||||||
|
tableName: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
try {
|
try {
|
||||||
@@ -1526,13 +1554,15 @@ function createGetTableInfoTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetEnumInfoTool(server: McpServer) {
|
function createGetEnumInfoTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_enum_info',
|
'get_enum_info',
|
||||||
'🏷️ Get enum type definition with all possible values',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: '🏷️ Get enum type definition with all possible values',
|
||||||
enumName: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
enumName: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
try {
|
try {
|
||||||
@@ -1573,9 +1603,11 @@ function createGetEnumInfoTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetAllEnumsTool(server: McpServer) {
|
function createGetAllEnumsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_all_enums',
|
'get_all_enums',
|
||||||
'🏷️ Get all enum types and their values',
|
{
|
||||||
|
description: '🏷️ Get all enum types and their values',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const enums = await DatabaseTool.getAllEnums();
|
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 { execSync } from 'node:child_process';
|
||||||
import { readFile, readdir } from 'node:fs/promises';
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod/v3';
|
||||||
|
|
||||||
export class MigrationsTool {
|
export class MigrationsTool {
|
||||||
static GetMigrations() {
|
static GetMigrations() {
|
||||||
@@ -35,9 +35,12 @@ export function registerGetMigrationsTools(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDiffMigrationTool(server: McpServer) {
|
function createDiffMigrationTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'diff_migrations',
|
'diff_migrations',
|
||||||
'Compare differences between the declarative schemas and the applied migrations in Supabase',
|
{
|
||||||
|
description:
|
||||||
|
'Compare differences between the declarative schemas and the applied migrations in Supabase',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const result = MigrationsTool.Diff();
|
const result = MigrationsTool.Diff();
|
||||||
const text = result.toString('utf8');
|
const text = result.toString('utf8');
|
||||||
@@ -55,13 +58,15 @@ function createDiffMigrationTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createCreateMigrationTool(server: McpServer) {
|
function createCreateMigrationTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'create_migration',
|
'create_migration',
|
||||||
'Create a new Supabase Postgres migration file',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Create a new Supabase Postgres migration file',
|
||||||
name: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const result = MigrationsTool.CreateMigration(state.name);
|
const result = MigrationsTool.CreateMigration(state.name);
|
||||||
@@ -80,13 +85,16 @@ function createCreateMigrationTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetMigrationContentTool(server: McpServer) {
|
function createGetMigrationContentTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_migration_content',
|
'get_migration_content',
|
||||||
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
path: z.string(),
|
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
|
||||||
}),
|
inputSchema: {
|
||||||
|
state: z.object({
|
||||||
|
path: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const content = await MigrationsTool.getMigrationContent(state.path);
|
const content = await MigrationsTool.getMigrationContent(state.path);
|
||||||
@@ -104,9 +112,12 @@ function createGetMigrationContentTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetMigrationsTool(server: McpServer) {
|
function createGetMigrationsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_migrations',
|
'get_migrations',
|
||||||
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
|
{
|
||||||
|
description:
|
||||||
|
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const migrations = await MigrationsTool.GetMigrations();
|
const migrations = await MigrationsTool.GetMigrations();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
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 { join } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod/v3';
|
||||||
|
|
||||||
// Custom phase for organizing user stories
|
// Custom phase for organizing user stories
|
||||||
interface CustomPhase {
|
interface CustomPhase {
|
||||||
@@ -34,6 +34,56 @@ interface UserStory {
|
|||||||
completedAt?: string;
|
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
|
// Structured PRD following ChatPRD format
|
||||||
interface StructuredPRD {
|
interface StructuredPRD {
|
||||||
introduction: {
|
introduction: {
|
||||||
@@ -54,8 +104,16 @@ interface StructuredPRD {
|
|||||||
successMetrics: string[];
|
successMetrics: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
nonGoals: string[];
|
||||||
|
outOfScope: string[];
|
||||||
|
assumptions: string[];
|
||||||
|
openQuestions: string[];
|
||||||
|
risks: RiskItem[];
|
||||||
|
dependencies: CrossDependency[];
|
||||||
|
|
||||||
userStories: UserStory[];
|
userStories: UserStory[];
|
||||||
customPhases?: CustomPhase[];
|
customPhases?: CustomPhase[];
|
||||||
|
storyTraceability: StoryTraceabilityMap[];
|
||||||
|
|
||||||
technicalRequirements: {
|
technicalRequirements: {
|
||||||
constraints: string[];
|
constraints: string[];
|
||||||
@@ -63,6 +121,13 @@ interface StructuredPRD {
|
|||||||
complianceRequirements: string[];
|
complianceRequirements: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
technicalContracts: {
|
||||||
|
apis: string[];
|
||||||
|
dataModels: string[];
|
||||||
|
permissions: string[];
|
||||||
|
integrationBoundaries: string[];
|
||||||
|
};
|
||||||
|
|
||||||
acceptanceCriteria: {
|
acceptanceCriteria: {
|
||||||
global: string[];
|
global: string[];
|
||||||
qualityStandards: string[];
|
qualityStandards: string[];
|
||||||
@@ -75,10 +140,30 @@ interface StructuredPRD {
|
|||||||
nonNegotiables: string[];
|
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: {
|
metadata: {
|
||||||
version: string;
|
version: string;
|
||||||
created: string;
|
created: string;
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
|
lastValidatedAt: string;
|
||||||
approver: string;
|
approver: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +203,7 @@ export class PRDManager {
|
|||||||
solutionDescription: string,
|
solutionDescription: string,
|
||||||
keyFeatures: string[],
|
keyFeatures: string[],
|
||||||
successMetrics: string[],
|
successMetrics: string[],
|
||||||
|
options?: CreateStructuredPRDOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
await this.ensurePRDsDirectory();
|
await this.ensurePRDsDirectory();
|
||||||
|
|
||||||
@@ -140,12 +226,25 @@ export class PRDManager {
|
|||||||
keyFeatures,
|
keyFeatures,
|
||||||
successMetrics,
|
successMetrics,
|
||||||
},
|
},
|
||||||
|
nonGoals: options?.nonGoals ?? [],
|
||||||
|
outOfScope: options?.outOfScope ?? [],
|
||||||
|
assumptions: options?.assumptions ?? [],
|
||||||
|
openQuestions: options?.openQuestions ?? [],
|
||||||
|
risks: [],
|
||||||
|
dependencies: [],
|
||||||
userStories: [],
|
userStories: [],
|
||||||
|
storyTraceability: [],
|
||||||
technicalRequirements: {
|
technicalRequirements: {
|
||||||
constraints: [],
|
constraints: [],
|
||||||
integrationNeeds: [],
|
integrationNeeds: [],
|
||||||
complianceRequirements: [],
|
complianceRequirements: [],
|
||||||
},
|
},
|
||||||
|
technicalContracts: {
|
||||||
|
apis: [],
|
||||||
|
dataModels: [],
|
||||||
|
permissions: [],
|
||||||
|
integrationBoundaries: [],
|
||||||
|
},
|
||||||
acceptanceCriteria: {
|
acceptanceCriteria: {
|
||||||
global: [],
|
global: [],
|
||||||
qualityStandards: [],
|
qualityStandards: [],
|
||||||
@@ -155,10 +254,27 @@ export class PRDManager {
|
|||||||
resources: [],
|
resources: [],
|
||||||
nonNegotiables: [],
|
nonNegotiables: [],
|
||||||
},
|
},
|
||||||
|
rolloutPlan: {
|
||||||
|
featureFlags: [],
|
||||||
|
migrationPlan: [],
|
||||||
|
rolloutPhases: [],
|
||||||
|
rollbackConditions: [],
|
||||||
|
},
|
||||||
|
measurementPlan: {
|
||||||
|
events: [],
|
||||||
|
dashboards: [],
|
||||||
|
baselineMetrics: [],
|
||||||
|
targetMetrics: [],
|
||||||
|
guardrailMetrics: [],
|
||||||
|
},
|
||||||
|
decisionLog: [],
|
||||||
|
agentTaskPackets: [],
|
||||||
|
changeLog: ['Initial PRD created'],
|
||||||
metadata: {
|
metadata: {
|
||||||
version: '1.0',
|
version: '2.0',
|
||||||
created: now,
|
created: now,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
|
lastValidatedAt: now,
|
||||||
approver: '',
|
approver: '',
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
@@ -294,6 +410,28 @@ export class PRDManager {
|
|||||||
suggestions.push('Add global acceptance criteria for quality standards');
|
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(
|
const vagueStories = prd.userStories.filter(
|
||||||
(s) => s.acceptanceCriteria.length < 2,
|
(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<{
|
static async getProjectStatus(filename: string): Promise<{
|
||||||
progress: number;
|
progress: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
nextSteps: string[];
|
nextSteps: string[];
|
||||||
blockers: UserStory[];
|
blockers: UserStory[];
|
||||||
|
openQuestions: string[];
|
||||||
|
highRisks: RiskItem[];
|
||||||
}> {
|
}> {
|
||||||
const prd = await this.loadPRD(filename);
|
const prd = await this.loadPRD(filename);
|
||||||
|
|
||||||
@@ -357,13 +508,16 @@ export class PRDManager {
|
|||||||
...nextPending.map((s) => `Start: ${s.title}`),
|
...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 {
|
return {
|
||||||
progress: prd.progress.overall,
|
progress: prd.progress.overall,
|
||||||
summary,
|
summary,
|
||||||
nextSteps,
|
nextSteps,
|
||||||
blockers,
|
blockers,
|
||||||
|
openQuestions: prd.openQuestions,
|
||||||
|
highRisks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +680,7 @@ export class PRDManager {
|
|||||||
const filePath = join(this.PRDS_DIR, filename);
|
const filePath = join(this.PRDS_DIR, filename);
|
||||||
try {
|
try {
|
||||||
const content = await readFile(filePath, 'utf8');
|
const content = await readFile(filePath, 'utf8');
|
||||||
return JSON.parse(content);
|
return this.normalizePRD(JSON.parse(content));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`PRD file "${filename}" not found`);
|
throw new Error(`PRD file "${filename}" not found`);
|
||||||
}
|
}
|
||||||
@@ -536,11 +690,101 @@ export class PRDManager {
|
|||||||
filename: string,
|
filename: string,
|
||||||
prd: StructuredPRD,
|
prd: StructuredPRD,
|
||||||
): Promise<void> {
|
): 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);
|
const filePath = join(this.PRDS_DIR, filename);
|
||||||
await writeFile(filePath, JSON.stringify(prd, null, 2), 'utf8');
|
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 {
|
private static extractTitleFromAction(action: string): string {
|
||||||
const cleaned = action.trim().toLowerCase();
|
const cleaned = action.trim().toLowerCase();
|
||||||
const words = cleaned.split(' ').slice(0, 4);
|
const words = cleaned.split(' ').slice(0, 4);
|
||||||
@@ -604,6 +848,58 @@ export class PRDManager {
|
|||||||
content += `- ${metric}\n`;
|
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`;
|
content += `\n## User Stories\n\n`;
|
||||||
|
|
||||||
const priorities: UserStory['priority'][] = ['P0', 'P1', 'P2', 'P3'];
|
const priorities: UserStory['priority'][] = ['P0', 'P1', 'P2', 'P3'];
|
||||||
@@ -637,6 +933,20 @@ export class PRDManager {
|
|||||||
content += `**Blocked:** ${prd.progress.blocked} stories need attention\n`;
|
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 += `\n---\n\n`;
|
||||||
content += `*Approver: ${prd.metadata.approver || 'TBD'}*\n`;
|
content += `*Approver: ${prd.metadata.approver || 'TBD'}*\n`;
|
||||||
|
|
||||||
@@ -661,6 +971,7 @@ export function registerPRDTools(server: McpServer) {
|
|||||||
createListPRDsTool(server);
|
createListPRDsTool(server);
|
||||||
createGetPRDTool(server);
|
createGetPRDTool(server);
|
||||||
createCreatePRDTool(server);
|
createCreatePRDTool(server);
|
||||||
|
createDeletePRDTool(server);
|
||||||
createAddUserStoryTool(server);
|
createAddUserStoryTool(server);
|
||||||
createUpdateStoryStatusTool(server);
|
createUpdateStoryStatusTool(server);
|
||||||
createExportMarkdownTool(server);
|
createExportMarkdownTool(server);
|
||||||
@@ -670,9 +981,11 @@ export function registerPRDTools(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createListPRDsTool(server: McpServer) {
|
function createListPRDsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'list_prds',
|
'list_prds',
|
||||||
'List all Product Requirements Documents',
|
{
|
||||||
|
description: 'List all Product Requirements Documents',
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const prds = await PRDManager.listPRDs();
|
const prds = await PRDManager.listPRDs();
|
||||||
|
|
||||||
@@ -702,13 +1015,15 @@ function createListPRDsTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetPRDTool(server: McpServer) {
|
function createGetPRDTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_prd',
|
'get_prd',
|
||||||
'Get the contents of a specific PRD file',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Get the contents of a specific PRD file',
|
||||||
filename: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const content = await PRDManager.getPRDContent(state.filename);
|
const content = await PRDManager.getPRDContent(state.filename);
|
||||||
@@ -726,20 +1041,27 @@ function createGetPRDTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createCreatePRDTool(server: McpServer) {
|
function createCreatePRDTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'create_prd',
|
'create_prd',
|
||||||
'Create a new structured PRD following ChatPRD best practices',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description:
|
||||||
title: z.string(),
|
'Create a new structured PRD following ChatPRD best practices',
|
||||||
overview: z.string(),
|
inputSchema: {
|
||||||
problemStatement: z.string(),
|
state: z.object({
|
||||||
marketOpportunity: z.string(),
|
title: z.string(),
|
||||||
targetUsers: z.array(z.string()),
|
overview: z.string(),
|
||||||
solutionDescription: z.string(),
|
problemStatement: z.string(),
|
||||||
keyFeatures: z.array(z.string()),
|
marketOpportunity: z.string(),
|
||||||
successMetrics: z.array(z.string()),
|
targetUsers: z.array(z.string()),
|
||||||
}),
|
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 }) => {
|
async ({ state }) => {
|
||||||
const filename = await PRDManager.createStructuredPRD(
|
const filename = await PRDManager.createStructuredPRD(
|
||||||
@@ -751,6 +1073,12 @@ function createCreatePRDTool(server: McpServer) {
|
|||||||
state.solutionDescription,
|
state.solutionDescription,
|
||||||
state.keyFeatures,
|
state.keyFeatures,
|
||||||
state.successMetrics,
|
state.successMetrics,
|
||||||
|
{
|
||||||
|
nonGoals: state.nonGoals,
|
||||||
|
outOfScope: state.outOfScope,
|
||||||
|
assumptions: state.assumptions,
|
||||||
|
openQuestions: state.openQuestions,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -765,19 +1093,47 @@ function createCreatePRDTool(server: McpServer) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAddUserStoryTool(server: McpServer) {
|
function createDeletePRDTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'add_user_story',
|
'delete_prd',
|
||||||
'Add a new user story to an existing PRD',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Delete an existing PRD file',
|
||||||
filename: z.string(),
|
inputSchema: {
|
||||||
userType: z.string(),
|
state: z.object({
|
||||||
action: z.string(),
|
filename: z.string(),
|
||||||
benefit: z.string(),
|
}),
|
||||||
acceptanceCriteria: z.array(z.string()),
|
},
|
||||||
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P2'),
|
},
|
||||||
}),
|
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(),
|
||||||
|
action: z.string(),
|
||||||
|
benefit: z.string(),
|
||||||
|
acceptanceCriteria: z.array(z.string()),
|
||||||
|
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P2'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const result = await PRDManager.addUserStory(
|
const result = await PRDManager.addUserStory(
|
||||||
@@ -802,23 +1158,25 @@ function createAddUserStoryTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createUpdateStoryStatusTool(server: McpServer) {
|
function createUpdateStoryStatusTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'update_story_status',
|
'update_story_status',
|
||||||
'Update the status of a specific user story',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Update the status of a specific user story',
|
||||||
filename: z.string(),
|
inputSchema: {
|
||||||
storyId: z.string(),
|
state: z.object({
|
||||||
status: z.enum([
|
filename: z.string(),
|
||||||
'not_started',
|
storyId: z.string(),
|
||||||
'research',
|
status: z.enum([
|
||||||
'in_progress',
|
'not_started',
|
||||||
'review',
|
'research',
|
||||||
'completed',
|
'in_progress',
|
||||||
'blocked',
|
'review',
|
||||||
]),
|
'completed',
|
||||||
notes: z.string().optional(),
|
'blocked',
|
||||||
}),
|
]),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const result = await PRDManager.updateStoryStatus(
|
const result = await PRDManager.updateStoryStatus(
|
||||||
@@ -841,13 +1199,15 @@ function createUpdateStoryStatusTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createExportMarkdownTool(server: McpServer) {
|
function createExportMarkdownTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'export_prd_markdown',
|
'export_prd_markdown',
|
||||||
'Export PRD as markdown for visualization and sharing',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Export PRD as markdown for visualization and sharing',
|
||||||
filename: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const markdownFile = await PRDManager.exportAsMarkdown(state.filename);
|
const markdownFile = await PRDManager.exportAsMarkdown(state.filename);
|
||||||
@@ -865,13 +1225,15 @@ function createExportMarkdownTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetImplementationPromptsTool(server: McpServer) {
|
function createGetImplementationPromptsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_implementation_prompts',
|
'get_implementation_prompts',
|
||||||
'Generate Claude Code implementation prompts from PRD',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Generate Claude Code implementation prompts from PRD',
|
||||||
filename: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const prompts = await PRDManager.generateImplementationPrompts(
|
const prompts = await PRDManager.generateImplementationPrompts(
|
||||||
@@ -904,13 +1266,15 @@ function createGetImplementationPromptsTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetImprovementSuggestionsTool(server: McpServer) {
|
function createGetImprovementSuggestionsTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_improvement_suggestions',
|
'get_improvement_suggestions',
|
||||||
'Get AI-powered suggestions to improve the PRD',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Get AI-powered suggestions to improve the PRD',
|
||||||
filename: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const suggestions = await PRDManager.getImprovementSuggestions(
|
const suggestions = await PRDManager.getImprovementSuggestions(
|
||||||
@@ -943,13 +1307,15 @@ function createGetImprovementSuggestionsTool(server: McpServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGetProjectStatusTool(server: McpServer) {
|
function createGetProjectStatusTool(server: McpServer) {
|
||||||
return server.tool(
|
return server.registerTool(
|
||||||
'get_project_status',
|
'get_project_status',
|
||||||
'Get comprehensive status overview of the PRD project',
|
|
||||||
{
|
{
|
||||||
state: z.object({
|
description: 'Get comprehensive status overview of the PRD project',
|
||||||
filename: z.string(),
|
inputSchema: {
|
||||||
}),
|
state: z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async ({ state }) => {
|
async ({ state }) => {
|
||||||
const status = await PRDManager.getProjectStatus(state.filename);
|
const status = await PRDManager.getProjectStatus(state.filename);
|
||||||
@@ -970,6 +1336,22 @@ function createGetProjectStatusTool(server: McpServer) {
|
|||||||
status.blockers.forEach((blocker) => {
|
status.blockers.forEach((blocker) => {
|
||||||
result += `- ${blocker.title}: ${blocker.notes || 'No details provided'}\n`;
|
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 {
|
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
|
||||||
|
>;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user