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:
Giancarlo Buomprisco
2026-02-11 20:42:01 +01:00
committed by GitHub
parent 059408a70a
commit f3ac595d06
123 changed files with 17803 additions and 5265 deletions

View File

@@ -4,9 +4,9 @@ import { DatabaseToolsInterface } from './_components/database-tools-interface';
import { loadDatabaseToolsData } from './_lib/server/database-tools.loader';
interface DatabasePageProps {
searchParams: {
searchParams: Promise<{
search?: string;
};
}>;
}
export const metadata: Metadata = {
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
};
async function DatabasePage({ searchParams }: DatabasePageProps) {
const searchTerm = searchParams.search || '';
const searchTerm = (await searchParams).search || '';
// Load all database data server-side
const databaseData = await loadDatabaseToolsData();

View File

@@ -1,10 +1,12 @@
import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form';
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
import { getVariable } from '@/app/variables/lib/env-scanner';
import { EnvMode } from '@/app/variables/lib/types';
import { EnvModeSelector } from '@/components/env-mode-selector';
import { IFrame } from '@/components/iframe';
import {
createKitEmailsDeps,
createKitEmailsService,
} from '@kit/mcp-server/emails';
import { findWorkspaceRoot, getVariable } from '@kit/mcp-server/env';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import {
@@ -15,6 +17,8 @@ import {
} from '@kit/ui/dialog';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
type EnvMode = 'development' | 'production';
type EmailPageProps = React.PropsWithChildren<{
params: Promise<{
id: string;
@@ -31,25 +35,28 @@ export default async function EmailPage(props: EmailPageProps) {
const { id } = await props.params;
const mode = (await props.searchParams).mode ?? 'development';
const template = await loadEmailTemplate(id);
const emailSettings = await getEmailSettings(mode);
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
const values: Record<string, string> = {
emails: 'Emails',
'invite-email': 'Invite Email',
'account-delete-email': 'Account Delete Email',
'confirm-email': 'Confirm Email',
'change-email-address-email': 'Change Email Address Email',
'reset-password-email': 'Reset Password Email',
'magic-link-email': 'Magic Link Email',
'otp-email': 'OTP Email',
};
const [result, { templates }, emailSettings] = await Promise.all([
service.read({ id }),
service.list(),
getEmailSettings(mode),
]);
const html = result.renderedHtml ?? result.source;
const values: Record<string, string> = { emails: 'Emails' };
for (const t of templates) {
values[t.id] = t.name;
}
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
title={values[id]}
title={values[id] ?? id}
description={<AppBreadcrumbs values={values} />}
>
<EnvModeSelector mode={mode} />
@@ -77,7 +84,7 @@ export default async function EmailPage(props: EmailPageProps) {
<IFrame className={'flex flex-1 flex-col'}>
<div
className={'flex flex-1 flex-col'}
dangerouslySetInnerHTML={{ __html: template.html }}
dangerouslySetInnerHTML={{ __html: html }}
/>
</IFrame>
</PageBody>

View File

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

View File

@@ -1,6 +1,10 @@
'use server';
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
import {
createKitEmailsDeps,
createKitEmailsService,
} from '@kit/mcp-server/emails';
import { findWorkspaceRoot } from '@kit/mcp-server/env';
export async function sendEmailAction(params: {
template: string;
@@ -27,7 +31,10 @@ export async function sendEmailAction(params: {
},
});
const { html } = await loadEmailTemplate(params.template);
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
const result = await service.read({ id: params.template });
const html = result.renderedHtml ?? result.source;
return transporter.sendMail({
html,

View File

@@ -1,5 +1,10 @@
import Link from 'next/link';
import {
createKitEmailsDeps,
createKitEmailsService,
} from '@kit/mcp-server/emails';
import { findWorkspaceRoot } from '@kit/mcp-server/env';
import {
CardButton,
CardButtonHeader,
@@ -12,7 +17,16 @@ export const metadata = {
title: 'Emails',
};
const CATEGORY_LABELS: Record<string, string> = {
'supabase-auth': 'Supabase Auth Emails',
transactional: 'Transactional Emails',
};
export default async function EmailsPage() {
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
const { templates, categories } = await service.list();
return (
<Page style={'custom'}>
<PageHeader
@@ -22,73 +36,31 @@ export default async function EmailsPage() {
/>
<PageBody className={'gap-y-8'}>
<div className={'flex flex-col space-y-4'}>
<Heading level={5}>Supabase Auth Emails</Heading>
{categories.map((category) => {
const categoryTemplates = templates.filter(
(t) => t.category === category,
);
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
<CardButton asChild>
<Link href={'/emails/confirm-email'}>
<CardButtonHeader>
<CardButtonTitle>Confirm Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
return (
<div key={category} className={'flex flex-col space-y-4'}>
<Heading level={5}>
{CATEGORY_LABELS[category] ?? category}
</Heading>
<CardButton asChild>
<Link href={'/emails/change-email-address-email'}>
<CardButtonHeader>
<CardButtonTitle>Change Email Address Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/reset-password-email'}>
<CardButtonHeader>
<CardButtonTitle>Reset Password Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/magic-link-email'}>
<CardButtonHeader>
<CardButtonTitle>Magic Link Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div>
</div>
<div className={'flex flex-col space-y-4'}>
<Heading level={5}>Transactional Emails</Heading>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
<CardButton asChild>
<Link href={'/emails/account-delete-email'}>
<CardButtonHeader>
<CardButtonTitle>Account Delete Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/invite-email'}>
<CardButtonHeader>
<CardButtonTitle>Invite Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/otp-email'}>
<CardButtonHeader>
<CardButtonTitle>OTP Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div>
</div>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
{categoryTemplates.map((template) => (
<CardButton key={template.id} asChild>
<Link href={`/emails/${template.id}`}>
<CardButtonHeader>
<CardButtonTitle>{template.name}</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
))}
</div>
</div>
);
})}
</PageBody>
</Page>
);

View File

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

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

View 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');
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,37 @@
import { EnvMode } from '@/app/variables/lib/types';
import { EnvModeSelector } from '@/components/env-mode-selector';
import { ServiceCard } from '@/components/status-tile';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { createConnectivityService } from './lib/connectivity-service';
import { loadDashboardKitPrerequisites } from './lib/prerequisites-dashboard.loader';
import { loadDashboardKitStatus } from './lib/status-dashboard.loader';
type DashboardPageProps = React.PropsWithChildren<{
searchParams: Promise<{ mode?: EnvMode }>;
}>;
export default async function DashboardPage() {
const [status, prerequisites] = await Promise.all([
loadDashboardKitStatus(),
loadDashboardKitPrerequisites(),
]);
export default async function DashboardPage(props: DashboardPageProps) {
const mode = (await props.searchParams).mode ?? 'development';
const connectivityService = createConnectivityService(mode);
const failedRequiredCount = prerequisites.prerequisites.filter(
(item) => item.required && item.status === 'fail',
).length;
const [supabaseStatus, supabaseAdminStatus, stripeStatus] = await Promise.all(
[
connectivityService.checkSupabaseConnectivity(),
connectivityService.checkSupabaseAdminConnectivity(),
connectivityService.checkStripeConnected(),
],
const warnCount = prerequisites.prerequisites.filter(
(item) => item.status === 'warn',
).length;
const failedRequired = prerequisites.prerequisites.filter(
(item) => item.required && item.status === 'fail',
);
const prerequisiteRemedies = Array.from(
new Set(
failedRequired.flatMap((item) => [
...(item.remedies ?? []),
...(item.install_command ? [item.install_command] : []),
...(item.install_url ? [item.install_url] : []),
]),
),
);
return (
@@ -27,17 +39,148 @@ export default async function DashboardPage(props: DashboardPageProps) {
<PageHeader
displaySidebarTrigger={false}
title={'Dev Tool'}
description={'Check the status of your Supabase and Stripe services'}
>
<EnvModeSelector mode={mode} />
</PageHeader>
description={'Kit MCP status for this workspace'}
/>
<PageBody className={'space-y-8 py-2'}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<ServiceCard name={'Supabase API'} status={supabaseStatus} />
<ServiceCard name={'Supabase Admin'} status={supabaseAdminStatus} />
<ServiceCard name={'Stripe API'} status={stripeStatus} />
<ServiceCard
name={'Variant'}
status={{
status: 'success',
message: `${status.variant} (${status.variant_family})`,
}}
/>
<ServiceCard
name={'Runtime'}
status={{
status: 'success',
message: `${status.framework} • Node ${status.node_version}${status.package_manager}`,
}}
/>
<ServiceCard
name={'Dependencies'}
status={{
status: status.deps_installed ? 'success' : 'error',
message: status.deps_installed
? 'Dependencies installed'
: 'node_modules not found',
}}
/>
<ServiceCard
name={'Git'}
status={{
status:
status.git_branch === 'unknown'
? 'info'
: status.git_clean
? 'success'
: 'warning',
message: `${status.git_branch} (${status.git_clean ? 'clean' : 'dirty'}) • ${status.git_modified_files.length} modified • ${status.git_untracked_files.length} untracked`,
}}
/>
<ServiceCard
name={'Dev Server'}
status={{
status: status.services.app.running ? 'success' : 'error',
message: status.services.app.running
? `Running on port ${status.services.app.port}`
: 'Not running',
}}
/>
<ServiceCard
name={'Supabase'}
status={{
status: status.services.supabase.running ? 'success' : 'error',
message: status.services.supabase.running
? `Running${status.services.supabase.api_port ? ` (API ${status.services.supabase.api_port})` : ''}${status.services.supabase.studio_port ? ` (Studio ${status.services.supabase.studio_port})` : ''}`
: 'Not running',
}}
/>
<ServiceCard
name={'Merge Check'}
status={{
status:
status.git_merge_check.has_conflicts === true
? 'warning'
: status.git_merge_check.detectable
? 'success'
: 'info',
message: status.git_merge_check.detectable
? status.git_merge_check.has_conflicts
? `${status.git_merge_check.conflict_files.length} potential conflicts vs ${status.git_merge_check.target_branch}`
: `No conflicts vs ${status.git_merge_check.target_branch}`
: status.git_merge_check.message,
}}
/>
<ServiceCard
name={'Prerequisites'}
status={{
status:
prerequisites.overall === 'fail'
? 'error'
: prerequisites.overall === 'warn'
? 'warning'
: 'success',
message:
prerequisites.overall === 'fail'
? `${failedRequiredCount} required tools missing/mismatched`
: prerequisites.overall === 'warn'
? `${warnCount} optional warnings`
: 'All prerequisites satisfied',
}}
/>
</div>
{failedRequired.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Prerequisites Details</CardTitle>
</CardHeader>
<CardContent className={'space-y-4'}>
<div className={'space-y-2'}>
<p className={'text-sm font-medium'}>Missing or Mismatched</p>
<ul
className={
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
}
>
{failedRequired.map((item) => (
<li key={item.id}>
{item.name}
{item.version
? ` (installed ${item.version}, requires >= ${item.minimum_version})`
: ' (not installed)'}
</li>
))}
</ul>
</div>
<div className={'space-y-2'}>
<p className={'text-sm font-medium'}>Remediation</p>
<ul
className={
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
}
>
{prerequisiteRemedies.map((remedy) => (
<li key={remedy}>
<code>{remedy}</code>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
) : null}
</PageBody>
</Page>
);

View File

@@ -8,8 +8,8 @@ import { Progress } from '@kit/ui/progress';
import { Separator } from '@kit/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
import { UserStoryDisplay } from '../../../_components/user-story-display';
import type { PRDData } from '../../../_lib/server/prd-page.loader';
import { UserStoryDisplay } from '../../_components/user-story-display';
import type { PRDData } from '../../_lib/server/prd-page.loader';
interface PRDDetailViewProps {
filename: string;

View File

@@ -2,7 +2,7 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { loadPRDPageData } from '../../_lib/server/prd-page.loader';
import { loadPRDPageData } from '../_lib/server/prd-page.loader';
import { PRDDetailView } from './_components/prd-detail-view';
interface PRDPageProps {

View File

@@ -79,7 +79,7 @@ export function PRDsListInterface({ initialPrds }: PRDsListInterfaceProps) {
{filteredPrds.map((prd) => (
<Link
key={prd.filename}
href={`/mcp-server/prds/${prd.filename}`}
href={`/prds/${prd.filename}`}
className="block"
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">

View File

@@ -1,7 +1,7 @@
import { Metadata } from 'next';
import { loadPRDs } from '../_lib/server/prd-loader';
import { PRDsListInterface } from './_components/prds-list-interface';
import { loadPRDs } from './_lib/server/prd-loader';
export const metadata: Metadata = {
title: 'PRDs - MCP Server',

View File

@@ -33,27 +33,7 @@ import {
import { cn } from '@kit/ui/utils';
import { updateTranslationAction } from '../lib/server-actions';
import type { TranslationData, Translations } from '../lib/translations-loader';
function flattenTranslations(
obj: TranslationData,
prefix = '',
result: Record<string, string> = {},
) {
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
result[newKey] = value;
} else {
flattenTranslations(value, newKey, result);
}
}
return result;
}
type FlattenedTranslations = Record<string, Record<string, string>>;
import type { Translations } from '../lib/translations-loader';
export function TranslationsComparison({
translations,
@@ -74,35 +54,24 @@ export function TranslationsComparison({
[],
);
const locales = Object.keys(translations);
const baseLocale = locales[0]!;
const namespaces = Object.keys(translations[baseLocale] || {});
const { base_locale, locales, namespaces } = translations;
const [selectedLocales, setSelectedLocales] = useState<Set<string>>(
new Set(locales),
);
// Flatten translations for the selected namespace
const flattenedTranslations: FlattenedTranslations = {};
const [selectedNamespace, setSelectedNamespace] = useState(
namespaces[0] as string,
namespaces[0] ?? '',
);
for (const locale of locales) {
const namespaceData = translations[locale]?.[selectedNamespace];
if (namespaceData) {
flattenedTranslations[locale] = flattenTranslations(namespaceData);
} else {
flattenedTranslations[locale] = {};
}
}
// Get all unique keys across all translations
const allKeys = Array.from(
new Set(
Object.values(flattenedTranslations).flatMap((data) => Object.keys(data)),
locales.flatMap((locale) =>
Object.keys(
translations.translations[locale]?.[selectedNamespace] ?? {},
),
),
),
).sort();
@@ -143,7 +112,7 @@ export function TranslationsComparison({
return () => subscription.unsubscribe();
}, [subject$]);
if (locales.length === 0) {
if (locales.length === 0 || !base_locale) {
return <div>No translations found</div>;
}
@@ -228,12 +197,16 @@ export function TranslationsComparison({
</TableCell>
{visibleLocales.map((locale) => {
const translations = flattenedTranslations[locale] ?? {};
const translationsForLocale =
translations.translations[locale]?.[selectedNamespace] ??
{};
const baseTranslations =
flattenedTranslations[baseLocale] ?? {};
translations.translations[base_locale]?.[
selectedNamespace
] ?? {};
const value = translations[key];
const value = translationsForLocale[key];
const baseValue = baseTranslations[key];
const isMissing = !value;
const isDifferent = value !== baseValue;

View File

@@ -2,10 +2,14 @@
import { revalidatePath } from 'next/cache';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:url';
import { z } from 'zod';
import { findWorkspaceRoot } from '@kit/mcp-server/env';
import {
createKitTranslationsDeps,
createKitTranslationsService,
} from '@kit/mcp-server/translations';
const Schema = z.object({
locale: z.string().min(1),
namespace: z.string().min(1),
@@ -20,40 +24,18 @@ const Schema = z.object({
export async function updateTranslationAction(props: z.infer<typeof Schema>) {
// Validate the input
const { locale, namespace, key, value } = Schema.parse(props);
const rootPath = findWorkspaceRoot(process.cwd());
const root = resolve(process.cwd(), '..');
const filePath = `${root}apps/web/public/locales/${locale}/${namespace}.json`;
const service = createKitTranslationsService(
createKitTranslationsDeps(rootPath),
);
try {
// Read the current translations file
const translationsFile = readFileSync(filePath, 'utf8');
const translations = JSON.parse(translationsFile) as Record<string, any>;
// Update the nested key value
const keys = key.split('.') as string[];
let current = translations;
// Navigate through nested objects until the second-to-last key
for (let i = 0; i < keys.length - 1; i++) {
const currentKey = keys[i] as string;
if (!current[currentKey]) {
current[currentKey] = {};
}
current = current[currentKey];
}
// Set the value at the final key
const finalKey = keys[keys.length - 1] as string;
current[finalKey] = value;
// Write the updated translations back to the file
writeFileSync(filePath, JSON.stringify(translations, null, 2), 'utf8');
const result = await service.update({ locale, namespace, key, value });
revalidatePath(`/translations`);
return { success: true };
return result;
} catch (error) {
console.error('Failed to update translation:', error);
throw new Error('Failed to update translation');

View File

@@ -1,50 +1,21 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
export type TranslationData = {
[key: string]: string | TranslationData;
};
import { findWorkspaceRoot } from '@kit/mcp-server/env';
import {
createKitTranslationsDeps,
createKitTranslationsService,
} from '@kit/mcp-server/translations';
export type Translations = {
[locale: string]: {
[namespace: string]: TranslationData;
};
base_locale: string;
locales: string[];
namespaces: string[];
translations: Record<string, Record<string, Record<string, string>>>;
};
export async function loadTranslations() {
const localesPath = join(process.cwd(), '../web/public/locales');
const localesDirents = readdirSync(localesPath, { withFileTypes: true });
export async function loadTranslations(): Promise<Translations> {
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitTranslationsService(
createKitTranslationsDeps(rootPath),
);
const locales = localesDirents
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
const translations: Translations = {};
for (const locale of locales) {
translations[locale] = {};
const namespaces = readdirSync(join(localesPath, locale)).filter((file) =>
file.endsWith('.json'),
);
for (const namespace of namespaces) {
const namespaceName = namespace.replace('.json', '');
try {
const filePath = join(localesPath, locale, namespace);
const content = readFileSync(filePath, 'utf8');
translations[locale][namespaceName] = JSON.parse(content);
} catch (error) {
console.warn(
`Warning: Translation file not found for locale "${locale}" and namespace "${namespaceName}"`,
);
translations[locale][namespaceName] = {};
}
}
}
return translations;
return service.list();
}

View File

@@ -1,6 +1,5 @@
import { Metadata } from 'next';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { TranslationsComparison } from './components/translations-comparison';

View File

@@ -1,471 +1,6 @@
import 'server-only';
import fs from 'fs/promises';
import path from 'path';
import { envVariables } from './env-variables-model';
import {
AppEnvState,
EnvFileInfo,
EnvMode,
EnvVariableState,
ScanOptions,
} from './types';
// Define precedence order for each mode
const ENV_FILE_PRECEDENCE: Record<EnvMode, string[]> = {
development: [
'.env',
'.env.development',
'.env.local',
'.env.development.local',
],
production: [
'.env',
'.env.production',
'.env.local',
'.env.production.local',
],
};
function getSourcePrecedence(source: string, mode: EnvMode): number {
return ENV_FILE_PRECEDENCE[mode].indexOf(source);
}
export async function scanMonorepoEnv(
options: ScanOptions,
): Promise<EnvFileInfo[]> {
const {
rootDir = path.resolve(process.cwd(), '../..'),
apps = ['web'],
mode,
} = options;
const envTypes = ENV_FILE_PRECEDENCE[mode];
const appsDir = path.join(rootDir, 'apps');
const results: EnvFileInfo[] = [];
try {
const appDirs = await fs.readdir(appsDir);
for (const appName of appDirs) {
if (apps.length > 0 && !apps.includes(appName)) {
continue;
}
const appDir = path.join(appsDir, appName);
const stat = await fs.stat(appDir);
if (!stat.isDirectory()) {
continue;
}
const appInfo: EnvFileInfo = {
appName,
filePath: appDir,
variables: [],
};
for (const envType of envTypes) {
const envPath = path.join(appDir, envType);
try {
const content = await fs.readFile(envPath, 'utf-8');
const vars = parseEnvFile(content, envType);
appInfo.variables.push(...vars);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`Error reading ${envPath}:`, error);
}
}
}
if (appInfo.variables.length > 0) {
results.push(appInfo);
}
}
} catch (error) {
console.error('Error scanning monorepo:', error);
throw error;
}
return results;
}
function parseEnvFile(content: string, source: string) {
const variables: Array<{ key: string; value: string; source: string }> = [];
const lines = content.split('\n');
for (const line of lines) {
// Skip comments and empty lines
if (line.trim().startsWith('#') || !line.trim()) {
continue;
}
// Match KEY=VALUE pattern, handling quotes
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
const [, key = '', rawValue] = match;
let value = rawValue ?? '';
// Remove quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Handle escaped quotes within the value
value = value
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\');
variables.push({
key: key.trim(),
value: value.trim(),
source,
});
}
}
return variables;
}
export function processEnvDefinitions(
envInfo: EnvFileInfo,
mode: EnvMode,
): AppEnvState {
const variableMap: Record<string, EnvVariableState> = {};
// First pass: Collect all definitions
for (const variable of envInfo.variables) {
if (!variable) {
continue;
}
const model = envVariables.find((v) => variable.key === v.name);
if (!variableMap[variable.key]) {
variableMap[variable.key] = {
key: variable.key,
isVisible: true,
definitions: [],
effectiveValue: variable.value,
effectiveSource: variable.source,
isOverridden: false,
category: model ? model.category : 'Custom',
validation: {
success: true,
error: {
issues: [],
},
},
};
}
const varState = variableMap[variable.key];
if (!varState) {
continue;
}
varState.definitions.push({
key: variable.key,
value: variable.value,
source: variable.source,
});
}
// Second pass: Determine effective values and override status
for (const key in variableMap) {
const varState = variableMap[key];
if (!varState) {
continue;
}
// Sort definitions by mode-specific precedence
varState.definitions.sort(
(a, b) =>
getSourcePrecedence(a.source, mode) -
getSourcePrecedence(b.source, mode),
);
if (varState.definitions.length > 1) {
const lastDef = varState.definitions[varState.definitions.length - 1];
if (!lastDef) {
continue;
}
const highestPrecedence = getSourcePrecedence(lastDef.source, mode);
varState.isOverridden = true;
varState.effectiveValue = lastDef.value;
varState.effectiveSource = lastDef.source;
// Check for conflicts at highest precedence
const conflictingDefs = varState.definitions.filter(
(def) => getSourcePrecedence(def.source, mode) === highestPrecedence,
);
if (conflictingDefs.length > 1) {
varState.effectiveSource = `${varState.effectiveSource}`;
}
}
}
// after computing the effective values, we can check for errors
for (const key in variableMap) {
const model = envVariables.find((v) => key === v.name);
const varState = variableMap[key];
if (!varState) {
continue;
}
let validation: {
success: boolean;
error: {
issues: string[];
};
} = { success: true, error: { issues: [] } };
if (model) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
// First check if it's required but missing
if (model.required && !varState.effectiveValue) {
validation = {
success: false,
error: {
issues: [
`This variable is required but missing from your environment files`,
],
},
};
} else if (model.contextualValidation) {
// Then check contextual validation
const dependenciesMet = model.contextualValidation.dependencies.some(
(dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
return dep.condition(dependencyValue, allVariables);
},
);
if (dependenciesMet) {
// Only check for missing value or run validation if dependencies are met
if (!varState.effectiveValue) {
const dependencyErrors = model.contextualValidation.dependencies
.map((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (shouldValidate) {
const { success } = model.contextualValidation!.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (success) {
return null;
}
return dep.message;
}
return null;
})
.filter((message): message is string => message !== null);
validation = {
success: dependencyErrors.length === 0,
error: {
issues: dependencyErrors
.map((message) => message)
.filter((message) => !!message),
},
};
} else {
// If we have a value and dependencies are met, run contextual validation
const result = model.contextualValidation.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!message),
},
};
}
}
}
} else if (model.validate && varState.effectiveValue) {
// Only run regular validation if:
// 1. There's no contextual validation
// 2. There's a value to validate
const result = model.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!message),
},
};
}
}
}
varState.validation = validation;
}
// Final pass: Validate missing variables that are marked as required
// or as having contextual validation
for (const model of envVariables) {
// If the variable exists in appState, use that
const existingVar = variableMap[model.name];
if (existingVar) {
// If the variable is already in the map, skip it
continue;
}
if (model.required || model.contextualValidation) {
if (model.contextualValidation) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
const errors =
model?.contextualValidation?.dependencies
.map((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (!shouldValidate) {
return [];
}
const effectiveValue = allVariables[dep.variable] ?? '';
const validation = model.contextualValidation!.validate({
value: effectiveValue,
variables: allVariables,
mode,
});
if (validation) {
return [dep.message];
}
return [];
})
.flat() ?? ([] as string[]);
if (errors.length === 0) {
continue;
} else {
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: errors.map((error) => error),
},
},
};
}
}
// If it doesn't exist but is required or has contextual validation, create an empty state
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: [
`This variable is required but missing from your environment files`,
],
},
},
};
}
}
return {
appName: envInfo.appName,
filePath: envInfo.filePath,
mode,
variables: variableMap,
};
}
export async function getEnvState(
options: ScanOptions,
): Promise<AppEnvState[]> {
const envInfos = await scanMonorepoEnv(options);
return envInfos.map((info) => processEnvDefinitions(info, options.mode));
}
export async function getVariable(key: string, mode: EnvMode) {
// Get the processed environment state for all apps (you can limit to 'web' via options)
const envStates = await getEnvState({ mode, apps: ['web'] });
// Find the state for the "web" app.
const webState = envStates.find((state) => state.appName === 'web');
// Return the effectiveValue based on override status.
return webState?.variables[key]?.effectiveValue ?? '';
}
export {
getEnvState,
getVariable,
processEnvDefinitions,
scanMonorepoEnv,
} from '@kit/mcp-server/env';

File diff suppressed because it is too large Load Diff

View File

@@ -2,83 +2,35 @@
import { revalidatePath } from 'next/cache';
import { envVariables } from '@/app/variables/lib/env-variables-model';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:url';
import { z } from 'zod';
import {
createKitEnvDeps,
createKitEnvService,
findWorkspaceRoot,
} from '@kit/mcp-server/env';
const Schema = z.object({
name: z.string().min(1),
value: z.string(),
mode: z.enum(['development', 'production']),
});
/**
* Update the environment variable in the specified file.
* @param props
*/
export async function updateEnvironmentVariableAction(
props: z.infer<typeof Schema>,
) {
// Validate the input
const { name, mode, value } = Schema.parse(props);
const root = resolve(process.cwd(), '..');
const model = envVariables.find((item) => item.name === name);
// Determine the source file based on the mode
const source = (() => {
const isSecret = model?.secret ?? true;
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEnvService(createKitEnvDeps(rootPath));
switch (mode) {
case 'development':
if (isSecret) {
return '.env.local';
} else {
return '.env.development';
}
const result = await service.update({
key: name,
value,
mode,
});
case 'production':
if (isSecret) {
return '.env.production.local';
} else {
return '.env.production';
}
revalidatePath('/variables');
default:
throw new Error(`Invalid mode: ${mode}`);
}
})();
// check file exists, if not, create it
const filePath = `${root}/apps/web/${source}`;
if (!existsSync(filePath)) {
writeFileSync(filePath, '', 'utf8');
}
const sourceEnvFile = readFileSync(`${root}apps/web/${source}`, 'utf8');
let updatedEnvFile = '';
const isInSourceFile = sourceEnvFile.includes(name);
const isCommentedOut = sourceEnvFile.includes(`#${name}=`);
if (isInSourceFile && !isCommentedOut) {
updatedEnvFile = sourceEnvFile.replace(
new RegExp(`^${name}=.*`, 'm'),
`${name}=${value}`,
);
} else {
// if the key does not exist, append it to the end of the file
updatedEnvFile = `${sourceEnvFile}\n${name}=${value}`;
}
// write the updated content back to the file
writeFileSync(`${root}/apps/web/${source}`, updatedEnvFile, 'utf8');
revalidatePath(`/variables`);
return {
success: true,
message: `Updated ${name} in "${source}"`,
};
return result;
}

View File

@@ -1,12 +1,12 @@
import { use } from 'react';
import {
processEnvDefinitions,
scanMonorepoEnv,
} from '@/app/variables/lib/env-scanner';
import { EnvMode } from '@/app/variables/lib/types';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import {
createKitEnvDeps,
createKitEnvService,
findWorkspaceRoot,
} from '@kit/mcp-server/env';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager';
@@ -21,7 +21,7 @@ export const metadata = {
export default function VariablesPage({ searchParams }: VariablesPageProps) {
const { mode = 'development' } = use(searchParams);
const apps = use(scanMonorepoEnv({ mode }));
const apps = use(loadEnvStates(mode));
return (
<Page style={'custom'}>
@@ -36,19 +36,18 @@ export default function VariablesPage({ searchParams }: VariablesPageProps) {
<PageBody className={'overflow-hidden'}>
<div className={'flex h-full flex-1 flex-col space-y-4'}>
{apps.map((app) => {
const appEnvState = processEnvDefinitions(app, mode);
return (
<AppEnvironmentVariablesManager
key={app.appName}
state={appEnvState}
/>
);
})}
{apps.map((app) => (
<AppEnvironmentVariablesManager key={app.appName} state={app} />
))}
</div>
</PageBody>
</div>
</Page>
);
}
async function loadEnvStates(mode: EnvMode) {
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEnvService(createKitEnvDeps(rootPath));
return service.getAppState(mode);
}

View File

@@ -11,7 +11,6 @@ import {
LanguagesIcon,
LayoutDashboardIcon,
MailIcon,
ServerIcon,
} from 'lucide-react';
import {
@@ -55,20 +54,14 @@ const routes = [
Icon: LanguagesIcon,
},
{
label: 'MCP Server',
Icon: ServerIcon,
children: [
{
label: 'Database',
path: '/mcp-server/database',
Icon: DatabaseIcon,
},
{
label: 'PRD Manager',
path: '/mcp-server/prds',
Icon: FileTextIcon,
},
],
label: 'Database',
path: '/database',
Icon: DatabaseIcon,
},
{
label: 'PRD Manager',
path: '/prds',
Icon: FileTextIcon,
},
];

View File

@@ -2,12 +2,13 @@
import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Card, CardContent } from '@kit/ui/card';
export const ServiceStatus = {
CHECKING: 'checking',
SUCCESS: 'success',
WARNING: 'warning',
INFO: 'info',
ERROR: 'error',
} as const;
@@ -16,6 +17,8 @@ type ServiceStatusType = (typeof ServiceStatus)[keyof typeof ServiceStatus];
const StatusIcons = {
[ServiceStatus.CHECKING]: <AlertCircle className="h-6 w-6 text-yellow-500" />,
[ServiceStatus.SUCCESS]: <CheckCircle2 className="h-6 w-6 text-green-500" />,
[ServiceStatus.WARNING]: <AlertCircle className="h-6 w-6 text-amber-500" />,
[ServiceStatus.INFO]: <AlertCircle className="h-6 w-6 text-blue-500" />,
[ServiceStatus.ERROR]: <XCircle className="h-6 w-6 text-red-500" />,
};
@@ -30,7 +33,7 @@ interface ServiceCardProps {
export const ServiceCard = ({ name, status }: ServiceCardProps) => {
return (
<Card className="w-full max-w-2xl">
<CardContent className="pt-6">
<CardContent className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">

View File

@@ -11,7 +11,7 @@
},
"author": "Makerkit",
"devDependencies": {
"@playwright/test": "^1.58.1",
"@playwright/test": "^1.58.2",
"@supabase/supabase-js": "catalog:",
"@types/node": "catalog:",
"dotenv": "17.2.4",

View File

@@ -22,6 +22,7 @@ app/
For specialized implementation:
- `/feature-builder` - End-to-end feature implementation
- `/service-builder` - Server side services
- `/server-action-builder` - Server actions
- `/forms-builder` - Forms with validation
- `/navigation-config` - Adding routes and menu items

View File

@@ -72,10 +72,10 @@ export async function generateMetadata({
url: data.entry.url,
images: image
? [
{
url: image,
},
]
{
url: image,
},
]
: [],
},
twitter: {

View File

@@ -6,6 +6,7 @@
- **ALWAYS** validate admin status before operations
- **NEVER** bypass authentication or authorization
- **ALWAYS** audit admin operations with logging
- **ALWAYS** use `adminAction` to wrap admin actions @packages/features/admin/src/lib/server/utils/admin-action.ts
## Page Structure

View File

@@ -69,7 +69,7 @@ export function ErrorPageContent({
) : (
<Button asChild>
<Link href={backLink}>
<ArrowLeft className={'h-4 w-4 mr-1'} />
<ArrowLeft className={'mr-1 h-4 w-4'} />
<Trans i18nKey={backLabel} />
</Link>
</Button>
@@ -77,7 +77,7 @@ export function ErrorPageContent({
<Button asChild variant={'ghost'}>
<Link href={'/contact'}>
<MessageCircleQuestion className={'h-4 w-4 mr-1'} />
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
<Trans i18nKey={contactLabel} />
</Link>
</Button>

View File

@@ -34,7 +34,7 @@ const config = {
fullUrl: true,
},
},
serverExternalPackages: ['pino', 'thread-stream'],
serverExternalPackages: [],
// needed for supporting dynamic imports for local content
outputFileTracingIncludes: {
'/*': ['./content/**/*'],